b622f76a02
- 新增穿山甲 GroMore「聚合数据报告 API」对接,按天 T+1 拉取后台 revenue(预估)与 api_revenue(收益API,更接近结算),在广告收益报表大盘 + 按天趋势级展示,与客户端自报 eCPM 折算的预估并列对照;逐条广告事件行不动(仍是客户端预估)。 - 链路:integrations/pangle_report.py(MD5 签名,与官方文档两个测试向量逐字节一致)→ scripts/sync_pangle_revenue.py(拉昨天/--days 回补)→ 新表 ad_pangle_daily_revenue (repositories/ad_pangle_revenue.py:upsert + 按日聚合)→ admin/repositories/ad_revenue.py 汇总,新增 total_pangle_revenue_yuan / total_pangle_api_revenue_yuan / daily[].pangle_*。 - 穿山甲无用户/类型/场景维度:仅全量视图(未按 user/ad_type/feed_scene 过滤)给值,否则置 None;join key 用 ad_unit_id(=客户端配的 104xxx),非 code_id。 - 新增配置 PANGLE_REPORT_USER_ID/ROLE_ID/SECURITY_KEY(≠发奖 m-key)+ site_id→应用映射; 含单测(签名向量+分页解析);已真连穿山甲验证。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #92 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
149 lines
6.2 KiB
Python
149 lines
6.2 KiB
Python
"""穿山甲 GroMore「聚合数据报告 API」客户端 —— 按天拉取收益报表(只读、T+1)。
|
||
|
||
⚠️ 这是 **GroMore 数据拉取 API**,与发奖回调验签(integrations/pangle.py 的 m-key)是
|
||
两套完全不同的凭证与用途。凭证在后台「接入中心 → GroMore-API → 聚合数据报告 API」领取:
|
||
媒体账号 user_id、子账号 role_id、Security Key(secure_key)。
|
||
|
||
鉴权(文档 v2.x「方法一」):
|
||
1. 去掉请求参数里的 `sign` 字段与值为空的字段;
|
||
2. 其余参数按 key 字典序升序,拼成 `k1=v1&k2=v2&...&kn=vn`;
|
||
3. 末尾直接拼接 security_key(无分隔符),对整串做 MD5,取 32 位小写十六进制 = sign。
|
||
签名有 3 分钟过期(天级用 `timestamp` 秒级时间戳;小时级才用 `current_time` 字符串)。
|
||
|
||
数据口径要点(来自官方文档):
|
||
- 只返回【GroMore 聚合代码位】在 GroMore 内的数据(=我们 useMediation 的口径),
|
||
查不到穿山甲 SDK 自身的数据;
|
||
- **不提供分用户/设备维度**(官方 FAQ 明确拒绝),最细到 日期×应用×代码位×广告源;
|
||
- `revenue` = 预估收益(元,所有 ADN 都有);`api_revenue` = 收益Api(各 ADN 经 Reporting
|
||
回传、按实时汇率折算账号币种,更接近结算),需后台为该 ADN 配置 Reporting 才有、且不支持当天;
|
||
- 「今天」与「今天以前」必须分开查;天级跨度 ≤ 1 个月、不早于 12 个月。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import logging
|
||
import time
|
||
from collections.abc import Mapping
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger("shagua.pangle_report")
|
||
|
||
HOST = "https://www.csjplatform.com"
|
||
# 天级收益报表(路径与文档代码示例一致;另有小时级 get_hour_report_data,本服务只用天级)。
|
||
DAILY_PATH = "/union_media/open/api/mediation/get_daily_income_report_data"
|
||
VERSION = "2.0"
|
||
SIGN_TYPE = "MD5"
|
||
PAGE_LIMIT = 5000 # 文档上限;一次尽量多取,减少翻页
|
||
DEFAULT_TIMEOUT = 30.0
|
||
_MAX_PAGES = 200 # 翻页安全阀(5000×200=100w 行,远超我们规模),防异常 has_next 死循环
|
||
|
||
|
||
class PangleReportError(Exception):
|
||
"""GroMore 报表接口调用失败(未配置 / 网络 / 业务码非 100)。"""
|
||
|
||
|
||
def build_sign(params: Mapping[str, Any], security_key: str) -> str:
|
||
"""按文档「方法一」生成 sign:去 sign/空值 → key 升序 → k=v& 拼接 → 末尾接 secure_key → MD5(32 位小写)。
|
||
|
||
与官方 Python/Java 示例逐字节一致(见 tests/test_pangle_report.py 的两个测试向量)。
|
||
"""
|
||
items = [
|
||
(str(k), str(v))
|
||
for k, v in params.items()
|
||
if k != "sign" and v is not None and str(v) != ""
|
||
]
|
||
items.sort(key=lambda kv: kv[0])
|
||
raw = "&".join(f"{k}={v}" for k, v in items)
|
||
return hashlib.md5((raw + security_key).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def fetch_daily_report(
|
||
*,
|
||
start_date: str,
|
||
end_date: str,
|
||
# ⚠️ 用 ad_unit_id(GroMore 广告位ID = 我们的 104xxx),不要用 code_id(底层各 ADN 代码位,对不上)
|
||
dimensions: str = "date,site_id,ad_unit_id",
|
||
metrics: str = "revenue,api_revenue,ecpm,imp_cnt",
|
||
site_ids: str | None = None,
|
||
code_ids: str | None = None,
|
||
os: str | None = None,
|
||
network: str | None = None,
|
||
time_zone: int = 8,
|
||
limit: int = PAGE_LIMIT,
|
||
timeout: float = DEFAULT_TIMEOUT,
|
||
) -> list[dict[str, Any]]:
|
||
"""拉一个日期区间(北京时间,time_zone=8)的天级收益报表,自动翻页,返回 report_list 全量行。
|
||
|
||
每行是字符串字典(接口原值),形如
|
||
{"start_date": "2026-06-27", "site_id": "5830519", "ad_unit_id": "104142227",
|
||
"revenue": "1.23", "api_revenue": "1.05", "ecpm": "0.80", "imp_cnt": "1537", ...}。
|
||
业务码非 100 直接抛 PangleReportError(由调用方/脚本兜底,不静默吞)。
|
||
"""
|
||
if not settings.pangle_report_configured:
|
||
raise PangleReportError(
|
||
"PANGLE_REPORT_USER_ID / ROLE_ID / SECURITY_KEY 未配置,无法拉取 GroMore 报表"
|
||
)
|
||
|
||
base_params: dict[str, Any] = {
|
||
"user_id": settings.PANGLE_REPORT_USER_ID,
|
||
"role_id": settings.PANGLE_REPORT_ROLE_ID,
|
||
"version": VERSION,
|
||
"sign_type": SIGN_TYPE,
|
||
"start_date": start_date,
|
||
"end_date": end_date,
|
||
"dimensions": dimensions,
|
||
"metrics": metrics,
|
||
"time_zone": time_zone,
|
||
"limit": limit,
|
||
}
|
||
# 可选过滤(空则不传,避免进签名串)
|
||
for key, val in (
|
||
("site_ids", site_ids), ("code_ids", code_ids),
|
||
("os", os), ("network", network),
|
||
):
|
||
if val:
|
||
base_params[key] = val
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
offset = 0
|
||
try:
|
||
with httpx.Client(timeout=timeout) as client:
|
||
for _ in range(_MAX_PAGES):
|
||
params = dict(base_params)
|
||
params["offset"] = offset
|
||
# timestamp 每页临请求时取最新(3 分钟过期),并参与签名
|
||
params["timestamp"] = int(time.time())
|
||
params["sign"] = build_sign(params, settings.PANGLE_REPORT_SECURITY_KEY)
|
||
|
||
resp = client.get(HOST + DAILY_PATH, params=params)
|
||
resp.raise_for_status()
|
||
body = resp.json()
|
||
|
||
code = str(body.get("code"))
|
||
if code != "100":
|
||
raise PangleReportError(
|
||
f"GroMore 报表业务失败 code={code} message={body.get('message')!r} "
|
||
f"(101=验签失败/102=userid无效/107=无权限/118=无收益查看权限,详见文档状态码)"
|
||
)
|
||
|
||
data = body.get("data") or {}
|
||
page = data.get("report_list") or []
|
||
rows.extend(page)
|
||
|
||
# has_next=1 还有下一页;无该字段则按本页是否取满判断
|
||
has_next = str(data.get("has_next", "")) == "1"
|
||
if not page or not has_next:
|
||
break
|
||
offset += len(page)
|
||
except httpx.HTTPError as e:
|
||
raise PangleReportError(f"GroMore 报表请求异常: {e}") from e
|
||
|
||
logger.info(
|
||
"GroMore 天级报表拉取完成 %s~%s dims=%s 行数=%d", start_date, end_date, dimensions, len(rows)
|
||
)
|
||
return rows
|