f7d86011c1
- 新增 GET /admin/api/ad-revenue-report:展示条数/收益 + 复用金币审计逐条复算做发奖对账 - ad_ecpm/ad_reward/ad_feed_reward 各加 app_env + our_code_id 两列(alembic 迁移) - ecpm-report / feed-reward 接收并落库 app_env/our_code_id;激励发奖按 ad_session_id 回填 - ad_audit 抽出 audit_rows,报表与逐条审计复用同一复算口径 - 组级 matched 改「组内逐条全一致」,避免应发和==实发和的互相抵消掩盖错误 - list_feedbacks 改 offset 分页并返回 total(配合 admin 页码分页) - 反馈正文上限 _CONTENT_MAX 2000→200 - 文档:新增 admin-ad-revenue-report,更新 ecpm/feed-reward/feedback 及对应 db docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: #54 Co-authored-by: ouzhou <ouzhou@wonderable.ai> Co-committed-by: ouzhou <ouzhou@wonderable.ai>
240 lines
10 KiB
Python
240 lines
10 KiB
Python
"""admin 广告收益报表:按 用户 / 日期 / 广告类型 / 应用 / 代码位 聚合(单表含发奖对账)。
|
||
|
||
只读。聚合键 = user_id × ad_type × app_env × our_code_id;每组一行同时给出:
|
||
- 展示条数 + 收益:`ad_ecpm_record`(每行 = 客户端一次广告展示;收益 = Σ eCPM元 ÷ 1000)。
|
||
激励视频每次展示上报一行;信息流轮播每条展示各上报一行(每条独立 id,不复用会话)。
|
||
- 应发金币 / 实发金币:复用金币审计的**逐条复算**(`ad_audit.audit_rows`,与正式发奖同一公式口径,
|
||
不另写公式),把每条发奖记录的 expected/actual 按同维度求和;`matched` = 组内**逐条**全部一致
|
||
(任一条不符该组即不符,不用「应发和==实发和」以免互相抵消掩盖错误)。**不改发奖逻辑**,只读复算。
|
||
|
||
展示与发奖来自不同表,做并集:有展示无发奖(用户中途关 / 未达发奖)、有发奖无展示
|
||
(未上报 eCPM)都各自成行。app_env/our_code_id 旧数据为 NULL → 归到「来源未知」组。
|
||
|
||
⚠️ 局限:① 历史 Draw 发奖混在 ad_feed_reward_record 无类型标记,金币侧统一记 `feed`(迁移后 Draw
|
||
不再产生新数据)。② 聚合级只能看出「某组应发≠实发」,定位到具体哪条仍需逐条审计接口(ad-coin-audit)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import date as _date, datetime, timedelta, timezone
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.admin.repositories import ad_audit
|
||
from app.core import rewards
|
||
from app.models.ad_ecpm import AdEcpmRecord
|
||
|
||
|
||
def _cn_hour(dt: datetime) -> int:
|
||
"""created_at(UTC 口径)→ 北京时间小时(0–23)。naive 当 UTC 处理(sqlite),tz-aware 直接换算(pg)。"""
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
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)
|
||
d1 = _date.fromisoformat(date_to)
|
||
out: list[str] = []
|
||
d = d0
|
||
while d <= d1:
|
||
out.append(d.isoformat())
|
||
d += timedelta(days=1)
|
||
return out
|
||
|
||
|
||
# 审计行的 scene 与报表 ad_type 一一对应
|
||
_SCENE_TO_AD_TYPE = {"reward_video": "reward_video", "feed": "feed"}
|
||
|
||
|
||
def ad_revenue_report(
|
||
db: Session,
|
||
*,
|
||
date_from: str,
|
||
date_to: str,
|
||
user_id: int | None = None,
|
||
ad_type: str | None = None,
|
||
granularity: str = "day",
|
||
limit: int = 500,
|
||
) -> dict:
|
||
"""日期区间(北京时间,闭区间)广告收益聚合 + 发奖对账。单日时 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 回调可能比展示晚
|
||
一会儿,故同一次广告的展示与金币偶尔落相邻小时(按天则一致)。
|
||
"""
|
||
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) 展示条数 + 收益 ← ad_ecpm_record(report_date 闭区间;字符串 YYYY-MM-DD 字典序即日期序)
|
||
stmt = select(AdEcpmRecord).where(
|
||
AdEcpmRecord.report_date >= date_from,
|
||
AdEcpmRecord.report_date <= date_to,
|
||
)
|
||
if user_id is not None:
|
||
stmt = stmt.where(AdEcpmRecord.user_id == user_id)
|
||
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,
|
||
"created_at": rec.created_at,
|
||
"ecpm": rec.ecpm_raw,
|
||
"revenue_yuan": round(rev, 6),
|
||
"adn": rec.adn,
|
||
"slot_id": rec.slot_id,
|
||
})
|
||
|
||
# 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"],
|
||
})
|
||
|
||
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 "",
|
||
)
|
||
)
|
||
|
||
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)
|
||
|
||
# 按日期汇总(全量,不受 limit):供前端按天趋势图。
|
||
daily_map: dict[str, dict] = {}
|
||
for r in rows:
|
||
d = daily_map.get(r["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"]
|
||
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_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"])
|
||
),
|
||
"daily": daily,
|
||
"items": items,
|
||
}
|