From 128db7fb864988dc1f0830d64ea3bceb63ee84cd Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 24 Jun 2026 03:54:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=B9=BF=E5=91=8A=E6=94=B6?= =?UTF-8?q?=E7=9B=8A=E6=8A=A5=E8=A1=A8=E6=94=B9=E4=B8=BA=E9=80=90=E6=9D=A1?= =?UTF-8?q?=E5=B9=BF=E5=91=8A=E4=BA=8B=E4=BB=B6=20+=20=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=88=97=E6=98=BE=E7=A4=BA=E6=89=8B=E6=9C=BA=E5=8F=B7=20(#72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 报表主表从「按 用户×类型×应用×代码位 聚合」改成「逐条广告事件」(每次广告一行): - 激励视频:展示(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) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-app-server/pulls/72 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- app/admin/repositories/ad_audit.py | 4 + app/admin/repositories/ad_revenue.py | 297 +++++++++++++-------------- app/admin/schemas/ad_revenue.py | 46 +++-- 3 files changed, 175 insertions(+), 172 deletions(-) diff --git a/app/admin/repositories/ad_audit.py b/app/admin/repositories/ad_audit.py index 7cb2941..2804fc3 100644 --- a/app/admin/repositories/ad_audit.py +++ b/app/admin/repositories/ad_audit.py @@ -67,6 +67,7 @@ def _reward_video_rows( "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, @@ -88,6 +89,7 @@ def _reward_video_rows( "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, @@ -154,6 +156,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "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, @@ -174,6 +177,7 @@ def _feed_rows(db: Session, *, date: str, user_id: int | None) -> list[dict]: "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, diff --git a/app/admin/repositories/ad_revenue.py b/app/admin/repositories/ad_revenue.py index 206726b..71733a4 100644 --- a/app/admin/repositories/ad_revenue.py +++ b/app/admin/repositories/ad_revenue.py @@ -1,17 +1,18 @@ -"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。 +"""admin 广告收益报表:**逐条广告事件**列表(每行一次广告,含展示 + 发奖对账)。 -只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出: -- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。 - 激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。 -- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径, - 不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致 - (任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。 +只读。每行 = 一次广告事件(不再按用户聚合): +- **激励视频**:一次观看 = 1 条展示(ad_ecpm)+ 1 条发奖(ad_reward),按 ad_session_id 合并成一行, + 直接给出 eCPM / 收益 + 状态 / 应发 / 实发 / 一致;点开看该条金币复算因子。 +- **信息流**:轮播每条展示各一行(impressionId 各自独立);整场发奖(ad_feed_reward,client_event_id) + 与逐条展示无法对应,单独成「纯发奖」行。 +- 兜底:有展示无发奖(中途关 / 未达发奖)、有发奖无展示(未上报 eCPM)都各自成行。 -展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示 -(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。 +展示与收益来自 ad_ecpm_record(收益 = eCPM元 ÷ 1000);应发 / 实发金币复用金币审计逐条复算 +(ad_audit.audit_rows,与正式发奖同一公式口径,不另写公式)。合计与对账在全量上统计, +不受 limit(只截断 items)影响。 -⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw -不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。 +⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 feed。 +② 跨天 S2S 回调:同一次广告的展示与发奖偶尔落相邻日,各自按 report_date / reward_date 归日。 """ from __future__ import annotations @@ -23,6 +24,7 @@ from sqlalchemy.orm import Session from app.admin.repositories import ad_audit from app.core import rewards from app.models.ad_ecpm import AdEcpmRecord +from app.models.user import User def _cn_hour(dt: datetime) -> int: @@ -32,17 +34,6 @@ def _cn_hour(dt: datetime) -> int: return dt.astimezone(rewards.CN_TZ).hour -def _key( - report_date: str, - user_id: int, - ad_type: str, - app_env: str | None, - our_code_id: str | None, - hour: int | None, -) -> tuple: - return (report_date, user_id, ad_type, app_env or None, our_code_id or None, hour) - - def _date_range(date_from: str, date_to: str) -> list[str]: """闭区间内逐日 'YYYY-MM-DD' 串(含首尾)。date_from > date_to 时返回空。""" d0 = _date.fromisoformat(date_from) @@ -58,6 +49,18 @@ def _date_range(date_from: str, date_to: str) -> list[str]: # 审计行的 scene 与报表 ad_type 一一对应 _SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"} +# 发奖复算明细字段(展开下钻看「金币怎么算出来的」)——从 audit 行原样取这些 key。 +_REWARD_DETAIL_KEYS = ( + "record_id", "created_at", "status", "ecpm", "ecpm_factor", "units", + "lt_index_start", "lt_index_end", "lt_factor_start", "lt_factor_end", + "expected_coin", "actual_coin", "matched", +) + + +def _reward_detail(row: dict) -> dict: + """从 audit 行抽出发奖复算明细(给前端展开行渲染因子1/因子2/份数/LT/应发实发)。""" + return {k: row[k] for k in _REWARD_DETAIL_KEYS} + def ad_revenue_report( db: Session, @@ -69,44 +72,42 @@ def ad_revenue_report( granularity: str = "day", limit: int = 500, ) -> dict: - """日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 date_from==date_to。 + """日期区间(北京时间,闭区间)**逐条广告事件**列表 + 发奖对账。单日时 date_from==date_to。 - 聚合键含**日期**:report_date × user × ad_type × app_env × our_code_id(× 北京小时,granularity=hour)。 - ad_type: None=全部 / reward_video / feed / draw。 - granularity: "day"=按天 / "hour"=按小时(聚合键再加北京小时 0–23,每组一行)。 - limit 只截断展示明细,total 与 total_* / daily 在全量上统计(不受 limit 影响),数字始终可信。 - - 返回额外含 `daily`(按日期汇总的展示/收益/应发/实发,供前端按天趋势图;不受 limit 影响)。 - - 注:按小时下,展示按 ecpm 记录的小时、金币按发奖记录的小时各自归桶——S2S 回调可能比展示晚 - 一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。 + 每个 item = 一次广告事件(展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行)。 + ad_type: None=全部 / reward_video / feed / draw。granularity=hour 时每行带北京小时(由各自时间算)。 + limit 只截断 items(事件明细),total 与 total_* / daily 在全量上统计,数字始终可信。 """ by_hour = granularity == "hour" - groups: dict[tuple, dict] = {} - def _grp(key: tuple) -> dict: - g = groups.get(key) - if g is None: - rdate, uid, atype, app_env, code_id, hour = key - g = { - "report_date": rdate, - "user_id": uid, - "ad_type": atype, - "app_env": app_env, - "our_code_id": code_id, - "hour": hour, - "impressions": 0, - "revenue_yuan": 0.0, - "expected_coin": 0, - "actual_coin": 0, - "adns": set(), - "impression_records": [], # 该组逐条展示明细(展开下钻用) - "records": [], # 该组逐条发奖复算明细(展开下钻用) - } - groups[key] = g - return g + # 1) 发奖行(逐日 audit 复算):建 (user_id, ad_session_id) → [行] 映射用于和展示合并; + # 同时保留全量列表,未被展示合并的成「纯发奖」事件。 + reward_by_session: dict[tuple[int, str], list[dict]] = {} + all_reward_rows: list[dict] = [] + audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None + if ad_type is None or audit_scene is not None: + for d in _date_range(date_from, date_to): + for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): + row["_report_date"] = d + all_reward_rows.append(row) + sid = row.get("ad_session_id") + if sid: + reward_by_session.setdefault((row["user_id"], sid), []).append(row) - # 1) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序) + used_reward_ids: set[int] = set() + events: list[dict] = [] + + def _pop_reward(uid: int, sid: str | None) -> dict | None: + """取一条与 (uid, sid) 匹配且未被用过的发奖行(激励视频展示↔发奖按会话 1:1 合并)。""" + if not sid: + return None + for r in reward_by_session.get((uid, sid), ()): + if r["record_id"] not in used_reward_ids: + used_reward_ids.add(r["record_id"]) + return r + return None + + # 2) 展示记录(ad_ecpm):每条一个事件;能匹配到发奖则合并成「展示 + 发奖」一行。 stmt = select(AdEcpmRecord).where( AdEcpmRecord.report_date >= date_from, AdEcpmRecord.report_date <= date_to, @@ -116,124 +117,116 @@ def ad_revenue_report( if ad_type is not None: stmt = stmt.where(AdEcpmRecord.ad_type == ad_type) for rec in db.execute(stmt).scalars(): - hour = _cn_hour(rec.created_at) if by_hour else None - g = _grp(_key(rec.report_date, rec.user_id, rec.ad_type, rec.app_env, rec.our_code_id, hour)) - g["impressions"] += 1 - # 单次展示收益(元) = eCPM元 ÷ 1000(每千次→单次);用与发奖同源的解析,口径一致。 - rev = rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0 - g["revenue_yuan"] += rev - if rec.adn: - g["adns"].add(rec.adn) - g["impression_records"].append({ - "id": rec.id, + rwd = _pop_reward(rec.user_id, rec.ad_session_id) + ev = { + "event_key": f"imp-{rec.id}", + "report_date": rec.report_date, + "user_id": rec.user_id, + "ad_type": rec.ad_type, + "app_env": rec.app_env, + "our_code_id": rec.our_code_id, "created_at": rec.created_at, + "hour": _cn_hour(rec.created_at) if by_hour else None, + "has_impression": True, + "impressions": 1, "ecpm": rec.ecpm_raw, - "revenue_yuan": round(rev, 6), + # 单次展示收益(元)= eCPM元 ÷ 1000(每千次→单次);与发奖同源解析,口径一致。 + "revenue_yuan": round(rewards.parse_ecpm_yuan(rec.ecpm_raw) / 1000.0, 6), "adn": rec.adn, "slot_id": rec.slot_id, + } + if rwd is not None: + ev.update({ + "has_reward": True, + "status": rwd["status"], + "expected_coin": int(rwd["expected_coin"]), + "actual_coin": int(rwd["actual_coin"]), + "matched": bool(rwd["matched"]), + "reward_detail": _reward_detail(rwd), + }) + else: + # 纯展示(信息流逐条展示、激励视频缺发奖记录):不计对账,matched=True。 + ev.update({ + "has_reward": False, "status": None, + "expected_coin": 0, "actual_coin": 0, "matched": True, + "reward_detail": None, + }) + events.append(ev) + + # 3) 未被展示合并的发奖行 → 「纯发奖」事件(信息流整场发奖 / 有发奖无展示)。 + # 收益恒 0(收益只算展示侧,避免与展示行重复计)。 + for row in all_reward_rows: + if row["record_id"] in used_reward_ids: + continue + events.append({ + "event_key": f"rwd-{row['record_id']}", + "report_date": row["_report_date"], + "user_id": row["user_id"], + "ad_type": _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]), + "app_env": row.get("app_env"), + "our_code_id": row.get("our_code_id"), + "created_at": row["created_at"], + "hour": _cn_hour(row["created_at"]) if by_hour else None, + "has_impression": False, + "impressions": 0, + "ecpm": row["ecpm"], + "revenue_yuan": 0.0, + "adn": None, + "slot_id": None, + "has_reward": True, + "status": row["status"], + "expected_coin": int(row["expected_coin"]), + "actual_coin": int(row["actual_coin"]), + "matched": bool(row["matched"]), + "reward_detail": _reward_detail(row), }) - # 2) 应发 / 实发金币 ← 复用金币审计逐条复算(同一公式口径),按同维度求和。 - # audit_rows 是单日的,区间逐日调用,每天的行归到当天 report_date(语义与单日报表完全一致)。 - # ad_type=draw 时审计无对应记录(scene 只有 reward_video/feed),金币侧自然为空。 - audit_scene = _SCENE_TO_AD_TYPE.get(ad_type) if ad_type is not None else None - if ad_type is None or audit_scene is not None: - for d in _date_range(date_from, date_to): - for row in ad_audit.audit_rows(db, date=d, user_id=user_id, scene=audit_scene): - atype = _SCENE_TO_AD_TYPE.get(row["scene"], row["scene"]) - hour = _cn_hour(row["created_at"]) if by_hour else None - g = _grp(_key(d, row["user_id"], atype, row.get("app_env"), row.get("our_code_id"), hour)) - g["expected_coin"] += int(row["expected_coin"]) - g["actual_coin"] += int(row["actual_coin"]) - # 逐条明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致)——前端展开该组时下钻展示。 - g["records"].append({ - "record_id": row["record_id"], - "created_at": row["created_at"], - "status": row["status"], - "ecpm": row["ecpm"], - "ecpm_factor": row["ecpm_factor"], - "units": row["units"], - "lt_index_start": row["lt_index_start"], - "lt_index_end": row["lt_index_end"], - "lt_factor_start": row["lt_factor_start"], - "lt_factor_end": row["lt_factor_end"], - "expected_coin": row["expected_coin"], - "actual_coin": row["actual_coin"], - "matched": row["matched"], - }) + events.sort(key=lambda e: (e["report_date"], e["user_id"], e["created_at"])) - rows = list(groups.values()) - rows.sort( - key=lambda r: ( - r["report_date"], - r["user_id"], - r["hour"] if r["hour"] is not None else -1, - r["ad_type"] or "", - r["our_code_id"] or "", - ) - ) + # 补手机号(admin 展示用,完整不脱敏,与用户 / 钱包 / 比价记录页一致):批量一次查,避免 N+1。 + uids = {e["user_id"] for e in events} + phone_map: dict[int, str] = {} + if uids: + phone_map = { + uid: phone + for uid, phone in db.execute( + select(User.id, User.phone).where(User.id.in_(uids)) + ).all() + } + for e in events: + e["user_phone"] = phone_map.get(e["user_id"]) - total_impressions = sum(r["impressions"] for r in rows) - total_expected_coin = sum(r["expected_coin"] for r in rows) - total_actual_coin = sum(r["actual_coin"] for r in rows) - total_revenue_yuan = round(sum(r["revenue_yuan"] for r in rows), 6) + total_impressions = sum(e["impressions"] for e in events) + total_revenue_yuan = round(sum(e["revenue_yuan"] for e in events), 6) + total_expected_coin = sum(e["expected_coin"] for e in events) + total_actual_coin = sum(e["actual_coin"] for e in events) + mismatch_count = sum(1 for e in events if e["has_reward"] and not e["matched"]) # 按日期汇总(全量,不受 limit):供前端按天趋势图。 daily_map: dict[str, dict] = {} - for r in rows: - d = daily_map.get(r["report_date"]) + for e in events: + d = daily_map.get(e["report_date"]) if d is None: - d = { - "date": r["report_date"], - "impressions": 0, - "revenue_yuan": 0.0, - "expected_coin": 0, - "actual_coin": 0, - } - daily_map[r["report_date"]] = d - d["impressions"] += r["impressions"] - d["revenue_yuan"] += r["revenue_yuan"] - d["expected_coin"] += r["expected_coin"] - d["actual_coin"] += r["actual_coin"] + d = {"date": e["report_date"], "impressions": 0, "revenue_yuan": 0.0, + "expected_coin": 0, "actual_coin": 0} + daily_map[e["report_date"]] = d + d["impressions"] += e["impressions"] + d["revenue_yuan"] += e["revenue_yuan"] + d["expected_coin"] += e["expected_coin"] + d["actual_coin"] += e["actual_coin"] daily = [ {**d, "revenue_yuan": round(d["revenue_yuan"], 6)} for d in sorted(daily_map.values(), key=lambda x: x["date"]) ] - items = [ - { - "report_date": r["report_date"], - "user_id": r["user_id"], - "ad_type": r["ad_type"], - "app_env": r["app_env"], - "our_code_id": r["our_code_id"], - "hour": r["hour"], - "impressions": r["impressions"], - "revenue_yuan": round(r["revenue_yuan"], 6), - "expected_coin": r["expected_coin"], - "actual_coin": r["actual_coin"], - # 组内**逐条**全部一致才记一致——不能用「应发和==实发和」,否则一条多发+一条少发会互相 - # 抵消、求和相等被误判为 ✓,掩盖真实发奖错误。纯展示无发奖记录的组 all([]) → True。 - "matched": all(rec["matched"] for rec in r["records"]), - "adns": sorted(r["adns"]), - "impression_records": sorted( - r["impression_records"], key=lambda x: (x["created_at"], x["id"]) - ), - "records": sorted(r["records"], key=lambda x: (x["created_at"], x["record_id"])), - } - for r in rows[:limit] - ] - return { - "total": len(rows), - "truncated": len(rows) > limit, + "total": len(events), + "truncated": len(events) > limit, "total_impressions": total_impressions, "total_revenue_yuan": total_revenue_yuan, "total_expected_coin": total_expected_coin, "total_actual_coin": total_actual_coin, - "mismatch_count": sum( - 1 for r in rows if not all(rec["matched"] for rec in r["records"]) - ), + "mismatch_count": mismatch_count, "daily": daily, - "items": items, + "items": events[:limit], } diff --git a/app/admin/schemas/ad_revenue.py b/app/admin/schemas/ad_revenue.py index beeb132..360d285 100644 --- a/app/admin/schemas/ad_revenue.py +++ b/app/admin/schemas/ad_revenue.py @@ -1,7 +1,7 @@ """广告收益报表 schemas。 -按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合的只读报表:展示条数、收益(元)、金币、来源。 -字段 snake_case;收益按元(float),金币按整数。 +**逐条广告事件**只读报表:每行一次广告(激励视频展示+发奖按会话合并;信息流展示/发奖各自成行), +含 展示条数、收益(元)、应发/实发金币、对账。字段 snake_case;收益按元(float),金币按整数。 """ from __future__ import annotations @@ -50,27 +50,33 @@ class AdRevenueDaily(BaseModel): class AdRevenueRow(BaseModel): - """一个聚合组(report_date × user × ad_type × app_env × our_code_id)的汇总。""" + """一次广告事件(逐条一行):激励视频展示与发奖按 ad_session_id 合并;信息流展示 / 发奖各自成行。""" - report_date: str = Field(..., description="该组所属日期(北京时间 YYYY-MM-DD)") + event_key: str = Field(..., description="该事件稳定唯一键(imp-{ecpm_id} / rwd-{reward_id});前端 rowKey 用") + report_date: str = Field(..., description="该事件所属日期(北京时间 YYYY-MM-DD)") user_id: int + user_phone: str | None = Field(None, description="用户手机号(admin 展示用,完整;用户已删 / 查不到为空)") ad_type: str = Field(..., description="reward_video(激励视频) / feed(信息流) / draw(历史 Draw 信息流)") app_env: str | None = Field(None, description="我们的应用:prod(傻瓜比价正式) / test(测试应用);旧数据为空") our_code_id: str | None = Field(None, description="我们后台配置的代码位 ID(104xxx);旧数据为空") hour: int | None = Field(None, description="北京时间小时 0–23(granularity=hour 时有值;按天为 null)") - impressions: int = Field(..., description="展示条数(每条广告展示一条;轮播每条各计一次)") - revenue_yuan: float = Field(..., description="收益(元)= Σ(eCPM元 ÷ 1000);测试应用多为 0") - expected_coin: int = Field(..., description="应发金币(按公式复算,与金币审计同源)") - actual_coin: int = Field(..., description="实发金币(实际入账,按现发奖算法)") - matched: bool = Field(..., description="该组应发==实发(组内任一条不符则 false)") - adns: list[str] = Field(default_factory=list, description="实际填充的底层 ADN 子渠道集合(如 pangle/gdt)") - impression_records: list[AdRevenueImpression] = Field( - default_factory=list, - description="该组逐条展示明细(时间/eCPM/收益/adn);展开下钻用,无发奖也有(只要有展示)", - ) - records: list[AdRevenueRecord] = Field( - default_factory=list, - description="该组逐条发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);展开下钻用,纯展示无发奖记录的组为空", + created_at: datetime = Field(..., description="事件时间(有展示=展示时间,纯发奖=发奖时间)") + # ── 展示侧 ── + has_impression: bool = Field(..., description="是否有广告展示(信息流逐条展示=True,纯发奖行=False)") + impressions: int = Field(..., description="本行展示条数:有展示=1 / 纯发奖=0(供日汇总、趋势图复用)") + ecpm: str | None = Field(None, description="eCPM 原始值(分/千次);展示行取展示值,纯发奖行取发奖采用值") + revenue_yuan: float = Field(..., description="本次展示预估收益(元)= eCPM元 ÷ 1000;纯发奖行=0") + adn: str | None = Field(None, description="实际填充 ADN 子渠道(pangle/gdt…);纯发奖行为空") + slot_id: str | None = Field(None, description="底层 mediation rit(非我们配置的广告位 ID);纯发奖行为空") + # ── 发奖侧 ── + has_reward: bool = Field(..., description="是否有发奖记录(激励视频合并行 / 信息流整场发奖行=True;纯展示=False)") + status: str | None = Field(None, description="发奖状态 granted/closed_early/too_short/…;纯展示为空") + expected_coin: int = Field(..., description="应发金币(公式复算,与金币审计同源);纯展示=0") + actual_coin: int = Field(..., description="实发金币(实际入账);纯展示=0") + matched: bool = Field(..., description="本条应发==实发;纯展示恒 True(不计对账)") + reward_detail: AdRevenueRecord | None = Field( + None, + description="发奖复算明细(eCPM/因子1/份数/LT/因子2/应发/实发/一致);点行展开下钻用,纯展示为空", ) @@ -80,11 +86,11 @@ class AdRevenueReportOut(BaseModel): date_from: str = Field(..., description="报表起始日期(北京时间 YYYY-MM-DD)") date_to: str = Field(..., description="报表结束日期(北京时间 YYYY-MM-DD,闭区间;单日时与 date_from 相同)") daily: list[AdRevenueDaily] = Field(..., description="按日期汇总序列(全量,供按天趋势图)") - total: int = Field(..., description="聚合组总数(全量,不受 limit 影响)") + total: int = Field(..., description="广告事件总数(全量,不受 limit 影响)") truncated: bool = Field(..., description="明细是否被 limit 截断") total_impressions: int = Field(..., description="全量展示条数合计") total_revenue_yuan: float = Field(..., description="全量收益合计(元)") total_expected_coin: int = Field(..., description="全量应发金币合计") total_actual_coin: int = Field(..., description="全量实发金币合计") - mismatch_count: int = Field(..., description="应发≠实发的组数(=0 说明全部按公式发放)") - items: list[AdRevenueRow] = Field(..., description="聚合明细(按 用户→类型→代码位 排序)") + mismatch_count: int = Field(..., description="应发≠实发的发奖条数(=0 说明全部按公式发放)") + items: list[AdRevenueRow] = Field(..., description="逐条广告事件(按 日期→用户→时间 排序)")