930eff822c
## 背景 admin「领券数据」「比价记录」两个看板此前只能看到场景级(所有领券/比价)的广告收益, 无法定位「这一次领券/比价具体赚了多少」。根因:收益表 `ad_ecpm_record` 缺 `trace_id`, 无法与领券会话 / 比价记录按 trace 关联。 ## 改动 - **模型/迁移**:`ad_ecpm_record` 新增 `trace_id`(String(64), index, nullable); 迁移 `ad_ecpm_trace_id` 加列 + 索引 `ix_ad_ecpm_record_trace_id`,并**收敛当前两个 alembic head**(`11c44afbea58` selfstat + `merge_pages_override_coupon_slot`)为单 head。 - **上报链路**:`EcpmReportIn` 增 `trace_id` 字段;`/api/v1/ad/ecpm-report` 透传; `create_ecpm_record` 落库。 - **收益聚合**:新增 `revenue_yuan_by_trace(db, trace_ids)`——按 trace_id 聚合展示收益, 单条 = `min(eCPM元, ¥500钳顶)/1000`,与广告收益报表 `ad_revenue.py` **同口径**; 只吃当前页的 trace_id(逐页批量,索引命中,无 N+1)。 - **两个看板**:`CouponDataRow` / `AdminComparisonListItem` 增 `ad_revenue_yuan`; `coupon_data_report`(主表 + 用户抽屉)与 `list_comparison_records` 分页后逐页补该字段。 - **测试**:`tests/test_ad_ecpm_trace_revenue.py`(聚合/钳顶/落库)、 `tests/test_board_ad_revenue.py`(两看板 + 抽屉)。 --------- Co-authored-by: guke <guke@autohome.com.cn> Reviewed-on: #131
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
"""广告 eCPM 上报 CRUD(内部收益统计/对账)。
|
|
|
|
客户端在广告展示后(onAdShow)读到 eCPM,经鉴权接口上报,这里落库。鉴权接口已确保
|
|
user 存在(JWT),故不做 UnknownUser 校验。best-effort 上报:丢一两条不影响业务,
|
|
穿山甲后台报表是结算权威兜底。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core import rewards
|
|
from app.core.rewards import cn_today
|
|
from app.models.ad_ecpm import AdEcpmRecord
|
|
|
|
|
|
def create_ecpm_record(
|
|
db: Session,
|
|
user_id: int,
|
|
*,
|
|
ad_type: str,
|
|
ecpm_raw: str,
|
|
ad_session_id: str | None = None,
|
|
adn: str | None = None,
|
|
slot_id: str | None = None,
|
|
feed_scene: str | None = None,
|
|
trace_id: str | None = None,
|
|
app_env: str | None = None,
|
|
our_code_id: str | None = None,
|
|
) -> AdEcpmRecord:
|
|
"""落一条 eCPM 上报记录。report_date 用北京时间当天,供按天聚合。
|
|
|
|
app_env(prod/test)与 our_code_id(我们后台配置的 104xxx 代码位)供广告收益报表按
|
|
应用/代码位聚合;与 adn(实际填充子渠道)/slot_id(底层 rit)是两组不同口径。
|
|
"""
|
|
if ad_session_id:
|
|
existing = find_by_session(db, user_id=user_id, ad_session_id=ad_session_id)
|
|
if existing is not None:
|
|
return existing
|
|
rec = AdEcpmRecord(
|
|
user_id=user_id,
|
|
ad_type=ad_type,
|
|
ad_session_id=ad_session_id,
|
|
adn=adn,
|
|
slot_id=slot_id,
|
|
feed_scene=feed_scene,
|
|
trace_id=trace_id,
|
|
app_env=app_env,
|
|
our_code_id=our_code_id,
|
|
ecpm_raw=ecpm_raw,
|
|
report_date=cn_today().isoformat(),
|
|
)
|
|
db.add(rec)
|
|
try:
|
|
db.commit()
|
|
except IntegrityError:
|
|
db.rollback()
|
|
# 撞唯一约束 uq_ad_ecpm_record_session(全局按 ad_session_id、不含 user_id):并发同会话重复上报,
|
|
# 或同一 ad_session_id 已被先到的上报占用。本接口 fire-and-forget、best-effort —— 丢一条不影响业务
|
|
# (穿山甲后台才是结算权威),绝不向客户端抛 500。兜底查找须与唯一约束**同口径**(只按 ad_session_id、
|
|
# 不带 user_id):否则不同 user 上报了同一 ad_session_id 时,带 user_id 的查找会漏掉那条别人的记录 →
|
|
# 旧逻辑在此 raise 成 500(本应静默吞掉)。
|
|
existing = _find_by_session_global(db, ad_session_id)
|
|
if existing is not None:
|
|
return existing
|
|
# 极少:rollback 后既存记录又查不到(并发删除 / 竞态)。吞掉、返回未入库的内存对象(调用方不读返回值)。
|
|
return rec
|
|
db.refresh(rec)
|
|
return rec
|
|
|
|
|
|
def find_by_session(
|
|
db: Session, *, user_id: int, ad_session_id: str | None
|
|
) -> AdEcpmRecord | None:
|
|
"""按广告会话找 eCPM。旧客户端无 ad_session_id 时返回 None。"""
|
|
if not ad_session_id:
|
|
return None
|
|
return db.execute(
|
|
select(AdEcpmRecord).where(
|
|
AdEcpmRecord.user_id == user_id,
|
|
AdEcpmRecord.ad_session_id == ad_session_id,
|
|
)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def _find_by_session_global(db: Session, ad_session_id: str | None) -> AdEcpmRecord | None:
|
|
"""按 ad_session_id **全局**查找(与唯一约束 uq_ad_ecpm_record_session 同口径,不含 user_id)。
|
|
|
|
仅 create_ecpm_record 撞约束后兜底用:此时撞的是全局会话约束,既存记录可能属于**另一个 user**,
|
|
带 user_id 的 find_by_session 会漏掉它、导致误判「查无 → raise 500」。其它业务查「某 user 的某次
|
|
展示 eCPM」仍用 find_by_session(带 user_id,语义更准),不走这里。
|
|
"""
|
|
if not ad_session_id:
|
|
return None
|
|
return db.execute(
|
|
select(AdEcpmRecord).where(AdEcpmRecord.ad_session_id == ad_session_id)
|
|
).scalar_one_or_none()
|
|
|
|
|
|
def count_today(db: Session, user_id: int) -> int:
|
|
"""该用户今日(北京时间)上报的 eCPM 条数,排查/对账辅助用。"""
|
|
return db.execute(
|
|
select(func.count())
|
|
.select_from(AdEcpmRecord)
|
|
.where(
|
|
AdEcpmRecord.user_id == user_id,
|
|
AdEcpmRecord.report_date == cn_today().isoformat(),
|
|
)
|
|
).scalar_one()
|
|
|
|
|
|
def revenue_yuan_by_trace(db: Session, trace_ids: list[str]) -> dict[str, float]:
|
|
"""各 trace_id 的广告预估收益(元):按 trace_id 聚合 ad_ecpm_record 的展示收益。
|
|
|
|
单条展示收益 = min(eCPM元, AD_ECPM_MAX_FEN/100) / 1000(与 admin 广告收益报表同口径)。
|
|
ecpm_raw 是字符串且需逐条钳顶,故取回后 Python 求和(行数=本页各 trace 的展示条数,很小)。
|
|
trace_id 仅信息流(比价/领券)场景客户端带,激励视频/旧数据为 NULL,按 trace_id 过滤天然只算对应场景。
|
|
只喂**当前页**的 trace_id(≤ 一页条数);空集合直接返回(避免 IN () 非法)。
|
|
"""
|
|
if not trace_ids:
|
|
return {}
|
|
rows = db.execute(
|
|
select(AdEcpmRecord.trace_id, AdEcpmRecord.ecpm_raw).where(
|
|
AdEcpmRecord.trace_id.in_(trace_ids),
|
|
)
|
|
).all()
|
|
cap_yuan = rewards.AD_ECPM_MAX_FEN / 100.0
|
|
out: dict[str, float] = {}
|
|
for tid, ecpm_raw in rows:
|
|
if not tid:
|
|
continue
|
|
out[tid] = out.get(tid, 0.0) + min(rewards.parse_ecpm_yuan(ecpm_raw), cap_yuan) / 1000.0
|
|
return {tid: round(v, 6) for tid, v in out.items()}
|