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>
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""穿山甲 GroMore 数据 API 客户端测试。
|
|
|
|
重点锚定**签名算法**:用官方文档 Python / Java 示例里给出的「参数 + secure_key → sign」两个
|
|
测试向量做断言,保证我们的 build_sign 与穿山甲服务端逐字节一致(签错=接口直接 101 验签失败)。
|
|
另测拉取的翻页、业务码非 100 抛错、响应解析。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.integrations import pangle_report
|
|
from app.integrations.pangle_report import PangleReportError, build_sign
|
|
|
|
# ---- 签名:官方文档测试向量(完整 key 与期望 sign 来自文档代码注释)----
|
|
|
|
def test_build_sign_matches_doc_python_vector():
|
|
params = {
|
|
"current_time": "2022-01-06 16:10:54",
|
|
"sign_type": "MD5",
|
|
"version": "2.0",
|
|
"user_id": 459,
|
|
"role_id": 459,
|
|
}
|
|
sign = build_sign(params, "7f2ddb34d999ea61c00246ea3971529c")
|
|
assert sign == "57b9fed9b6c09cefdced7fa770fe341d"
|
|
|
|
|
|
def test_build_sign_matches_doc_java_vector():
|
|
params = {
|
|
"user_id": 459,
|
|
"role_id": 459,
|
|
"version": "2.0",
|
|
"sign_type": "MD5",
|
|
"current_time": "2022-01-06 16:05:49",
|
|
}
|
|
sign = build_sign(params, "1c589c70f7bb2746027ce90c33d2544c")
|
|
assert sign == "4d47b3a7679448c3da5c1d05e5291533"
|
|
|
|
|
|
def test_build_sign_drops_sign_and_empty_fields():
|
|
"""sign 字段与空值字段不参与签名:带上它们应得到与不带时相同的 sign。"""
|
|
base = {"version": "2.0", "user_id": 459, "role_id": 459, "sign_type": "MD5"}
|
|
with_noise = {**base, "sign": "deadbeef", "code_ids": "", "os": None}
|
|
assert build_sign(with_noise, "key123") == build_sign(base, "key123")
|
|
|
|
|
|
# ---- 拉取:伪造 httpx 客户端,验证翻页 / 解析 / 业务码 ----
|
|
|
|
class _FakeResp:
|
|
def __init__(self, payload: dict):
|
|
self._payload = payload
|
|
|
|
def raise_for_status(self) -> None:
|
|
pass
|
|
|
|
def json(self) -> dict:
|
|
return self._payload
|
|
|
|
|
|
class _FakeClient:
|
|
"""按 offset 返回分页响应;记录每次请求的 params 供断言。"""
|
|
|
|
def __init__(self, pages: list[dict]):
|
|
self._pages = pages
|
|
self.calls: list[dict] = []
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *a):
|
|
return False
|
|
|
|
def get(self, url: str, params: dict):
|
|
self.calls.append(params)
|
|
# 第 N 次调用取第 N 页(has_next 驱动翻页);超出用最后一页兜底
|
|
page = self._pages[min(len(self.calls) - 1, len(self._pages) - 1)]
|
|
return _FakeResp(page)
|
|
|
|
|
|
@pytest.fixture
|
|
def _configured(monkeypatch):
|
|
monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 459)
|
|
monkeypatch.setattr(settings, "PANGLE_REPORT_ROLE_ID", 459)
|
|
monkeypatch.setattr(settings, "PANGLE_REPORT_SECURITY_KEY", "key123")
|
|
|
|
|
|
def test_fetch_daily_report_paginates_and_parses(monkeypatch, _configured):
|
|
page1 = {
|
|
"code": "100", "message": "",
|
|
"data": {
|
|
"has_next": "1", "currency": "cny", "total": 3,
|
|
"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"},
|
|
],
|
|
},
|
|
}
|
|
page2 = {
|
|
"code": "100", "message": "",
|
|
"data": {
|
|
"has_next": "0", "currency": "cny", "total": 3,
|
|
"report_list": [
|
|
{"start_date": "2026-06-27", "site_id": "5832303", "ad_unit_id": "104137445",
|
|
"revenue": "0.00", "api_revenue": "", "ecpm": "0", "imp_cnt": "12"},
|
|
],
|
|
},
|
|
}
|
|
fake = _FakeClient([page1, page2])
|
|
monkeypatch.setattr(pangle_report.httpx, "Client", lambda *a, **k: fake)
|
|
|
|
rows = pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|
|
|
|
assert len(rows) == 2 # 两页都被取到(has_next 驱动翻页)
|
|
assert rows[0]["ad_unit_id"] == "104142227"
|
|
assert rows[1]["api_revenue"] == ""
|
|
# 每次请求都带上签名与必填鉴权参数
|
|
assert "sign" in fake.calls[0]
|
|
assert fake.calls[0]["user_id"] == 459 and fake.calls[0]["version"] == "2.0"
|
|
|
|
|
|
def test_fetch_daily_report_raises_on_business_error(monkeypatch, _configured):
|
|
fail = {"code": "118", "message": "no revenue permission", "data": {}}
|
|
monkeypatch.setattr(
|
|
pangle_report.httpx, "Client", lambda *a, **k: _FakeClient([fail])
|
|
)
|
|
with pytest.raises(PangleReportError):
|
|
pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|
|
|
|
|
|
def test_fetch_daily_report_no_config_raises(monkeypatch):
|
|
monkeypatch.setattr(settings, "PANGLE_REPORT_USER_ID", 0)
|
|
with pytest.raises(PangleReportError):
|
|
pangle_report.fetch_daily_report(start_date="2026-06-27", end_date="2026-06-27")
|