"""穿山甲 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