2236a8b3ee
改动:新增厂商推送配置、设备 push_vendor/push_token 字段、device push-test 接口、心跳超时厂商直推发送逻辑和对应测试。 验证:python -m pytest tests/test_device_push.py tests/test_auth.py tests/test_health.py 通过。
381 lines
12 KiB
Python
381 lines
12 KiB
Python
"""厂商直推集成。
|
|
|
|
服务端不再经由 JPush Push API 发送无障碍召回通知,而是按客户端上报的
|
|
push_vendor + push_token 分发到各手机厂商的服务端 API。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("shagua.vendor_push")
|
|
|
|
TYPE_ACCESSIBILITY_DISABLED = "accessibility_disabled"
|
|
SUPPORTED_VENDORS = frozenset({"honor", "vivo", "xiaomi", "oppo"})
|
|
|
|
|
|
class VendorPushError(Exception):
|
|
"""厂商推送调用失败。"""
|
|
|
|
|
|
@dataclass
|
|
class _CachedToken:
|
|
value: str
|
|
expires_at: float
|
|
|
|
|
|
_token_cache: dict[str, _CachedToken] = {}
|
|
|
|
|
|
def normalize_vendor(push_vendor: str | None) -> str | None:
|
|
if not push_vendor:
|
|
return None
|
|
vendor = push_vendor.strip().lower()
|
|
aliases = {
|
|
"hihonor": "honor",
|
|
"荣耀": "honor",
|
|
"mi": "xiaomi",
|
|
"小米": "xiaomi",
|
|
"oneplus": "oppo",
|
|
"realme": "oppo",
|
|
}
|
|
return aliases.get(vendor, vendor)
|
|
|
|
|
|
def send_accessibility_disabled(
|
|
push_vendor: str,
|
|
push_token: str,
|
|
*,
|
|
title: str = "保护已关闭",
|
|
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
|
) -> dict[str, Any]:
|
|
"""按厂商 token 向单台设备发送无障碍掉线通知。"""
|
|
vendor = normalize_vendor(push_vendor)
|
|
token = push_token.strip() if push_token else ""
|
|
if not vendor or vendor not in SUPPORTED_VENDORS:
|
|
raise VendorPushError(f"unsupported push vendor: {push_vendor}")
|
|
if not token:
|
|
raise VendorPushError("push token is empty")
|
|
|
|
dispatch: dict[str, Callable[[str, str, str], dict[str, Any]]] = {
|
|
"honor": _send_honor,
|
|
"vivo": _send_vivo,
|
|
"xiaomi": _send_xiaomi,
|
|
"oppo": _send_oppo,
|
|
}
|
|
return dispatch[vendor](token, title, alert)
|
|
|
|
|
|
def _extras() -> dict[str, str]:
|
|
return {"type": TYPE_ACCESSIBILITY_DISABLED}
|
|
|
|
|
|
def _require(value: str, name: str) -> str:
|
|
if not value:
|
|
raise VendorPushError(f"{name} not configured")
|
|
return value
|
|
|
|
|
|
def _request_json(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
expected_status: tuple[int, ...] = (200,),
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
resp = httpx.request(
|
|
method,
|
|
url,
|
|
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
|
**kwargs,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise VendorPushError(f"push http error: {e}") from e
|
|
|
|
if resp.status_code not in expected_status:
|
|
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
|
raise VendorPushError(f"push http {resp.status_code}")
|
|
try:
|
|
return resp.json()
|
|
except ValueError as e:
|
|
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
|
|
|
|
|
def _request_form(
|
|
method: str,
|
|
url: str,
|
|
*,
|
|
expected_status: tuple[int, ...] = (200,),
|
|
**kwargs: Any,
|
|
) -> dict[str, Any]:
|
|
try:
|
|
resp = httpx.request(
|
|
method,
|
|
url,
|
|
timeout=settings.PUSH_REQUEST_TIMEOUT_SEC,
|
|
**kwargs,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise VendorPushError(f"push http error: {e}") from e
|
|
|
|
if resp.status_code not in expected_status:
|
|
logger.error("vendor push http failed url=%s http=%s body=%s", url, resp.status_code, resp.text[:500])
|
|
raise VendorPushError(f"push http {resp.status_code}")
|
|
try:
|
|
return resp.json()
|
|
except ValueError as e:
|
|
raise VendorPushError(f"push invalid json: {resp.text[:200]}") from e
|
|
|
|
|
|
def _cache_get(key: str) -> str | None:
|
|
cached = _token_cache.get(key)
|
|
if cached and cached.expires_at > time.time() + 60:
|
|
return cached.value
|
|
return None
|
|
|
|
|
|
def _cache_put(key: str, value: str, expires_in: int | float | None) -> str:
|
|
ttl = int(expires_in or 3600)
|
|
_token_cache[key] = _CachedToken(value=value, expires_at=time.time() + max(60, ttl - 60))
|
|
return value
|
|
|
|
|
|
def _honor_access_token() -> str:
|
|
cache_key = "honor"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
return cached
|
|
client_id = _require(settings.HONOR_PUSH_CLIENT_ID, "HONOR_PUSH_CLIENT_ID")
|
|
client_secret = _require(settings.HONOR_PUSH_CLIENT_SECRET, "HONOR_PUSH_CLIENT_SECRET")
|
|
data = _request_form(
|
|
"POST",
|
|
settings.HONOR_PUSH_TOKEN_ENDPOINT,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
token = data.get("access_token")
|
|
if not token:
|
|
raise VendorPushError(f"honor auth failed: {data}")
|
|
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
|
|
|
|
|
def _send_honor(token: str, title: str, alert: str) -> dict[str, Any]:
|
|
app_id = _require(settings.HONOR_PUSH_APP_ID, "HONOR_PUSH_APP_ID")
|
|
access_token = _honor_access_token()
|
|
payload = {
|
|
"data": json.dumps(_extras(), ensure_ascii=False),
|
|
"notification": {"title": title, "body": alert},
|
|
"android": {
|
|
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
|
"targetUserType": 1,
|
|
"notification": {
|
|
"title": title,
|
|
"body": alert,
|
|
"clickAction": {"type": 3},
|
|
"importance": "NORMAL",
|
|
},
|
|
},
|
|
"token": [token],
|
|
}
|
|
data = _request_json(
|
|
"POST",
|
|
settings.HONOR_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
|
json=payload,
|
|
headers={
|
|
"Content-Type": "application/json; charset=UTF-8",
|
|
"Authorization": f"Bearer {access_token}",
|
|
"timestamp": str(int(time.time() * 1000)),
|
|
},
|
|
)
|
|
code = data.get("code")
|
|
if code is not None and int(code) != 200:
|
|
raise VendorPushError(f"honor push failed: {data}")
|
|
return data
|
|
|
|
|
|
def _vivo_auth_token() -> str:
|
|
cache_key = "vivo"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
return cached
|
|
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
|
app_key = _require(settings.VIVO_PUSH_APP_KEY, "VIVO_PUSH_APP_KEY")
|
|
app_secret = _require(settings.VIVO_PUSH_APP_SECRET, "VIVO_PUSH_APP_SECRET")
|
|
timestamp = str(int(time.time() * 1000))
|
|
sign = hashlib.md5(f"{app_id}{app_key}{timestamp}{app_secret}".encode()).hexdigest() # noqa: S324
|
|
data = _request_json(
|
|
"POST",
|
|
settings.VIVO_PUSH_AUTH_ENDPOINT,
|
|
json={
|
|
"appId": app_id,
|
|
"appKey": app_key,
|
|
"timestamp": timestamp,
|
|
"sign": sign,
|
|
},
|
|
headers={"Content-Type": "application/json"},
|
|
)
|
|
if int(data.get("result", -1)) != 0:
|
|
raise VendorPushError(f"vivo auth failed: {data}")
|
|
token = data.get("authToken")
|
|
if not token:
|
|
raise VendorPushError(f"vivo auth missing authToken: {data}")
|
|
return _cache_put(cache_key, str(token), 24 * 3600)
|
|
|
|
|
|
def _send_vivo(token: str, title: str, alert: str) -> dict[str, Any]:
|
|
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
|
auth_token = _vivo_auth_token()
|
|
body: dict[str, Any] = {
|
|
"appId": app_id,
|
|
"regId": token,
|
|
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
|
"title": title,
|
|
"content": alert,
|
|
"timeToLive": settings.PUSH_TIME_TO_LIVE_SEC,
|
|
"skipType": 1,
|
|
"requestId": uuid.uuid4().hex,
|
|
"pushMode": settings.VIVO_PUSH_MODE,
|
|
"clientCustomMap": _extras(),
|
|
}
|
|
if settings.VIVO_PUSH_CATEGORY:
|
|
body["category"] = settings.VIVO_PUSH_CATEGORY
|
|
data = _request_json(
|
|
"POST",
|
|
settings.VIVO_PUSH_SEND_ENDPOINT,
|
|
json=body,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"authToken": auth_token,
|
|
},
|
|
)
|
|
if int(data.get("result", -1)) != 0:
|
|
raise VendorPushError(f"vivo push failed: {data}")
|
|
return data
|
|
|
|
|
|
def _send_xiaomi(token: str, title: str, alert: str) -> dict[str, Any]:
|
|
app_secret = _require(settings.XIAOMI_PUSH_APP_SECRET, "XIAOMI_PUSH_APP_SECRET")
|
|
message_title = settings.XIAOMI_PUSH_TEMPLATE_TITLE.strip() or title
|
|
message_description = settings.XIAOMI_PUSH_TEMPLATE_DESCRIPTION.strip() or alert
|
|
body = {
|
|
"registration_id": token,
|
|
"restricted_package_name": settings.ANDROID_PACKAGE_NAME,
|
|
"title": message_title,
|
|
"description": message_description,
|
|
"payload": json.dumps(_extras(), ensure_ascii=False),
|
|
"pass_through": "0",
|
|
"notify_type": "-1",
|
|
"time_to_live": str(settings.PUSH_TIME_TO_LIVE_SEC * 1000),
|
|
"extra.notify_effect": "1",
|
|
}
|
|
if settings.XIAOMI_PUSH_CHANNEL_ID:
|
|
body["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
|
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
|
body["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
|
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
|
body["extra.template_param"] = _xiaomi_template_param(title, alert)
|
|
data = _request_form(
|
|
"POST",
|
|
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
|
data=body,
|
|
headers={"Authorization": f"key={app_secret}"},
|
|
)
|
|
code = data.get("code")
|
|
if code not in (0, "0", None):
|
|
raise VendorPushError(f"xiaomi push failed: {data}")
|
|
if str(data.get("result", "ok")).lower() not in ("ok", "success"):
|
|
raise VendorPushError(f"xiaomi push failed: {data}")
|
|
return data
|
|
|
|
|
|
def _xiaomi_template_param(title: str, alert: str) -> str:
|
|
rendered = (
|
|
settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON
|
|
.replace("{title}", title)
|
|
.replace("{alert}", alert)
|
|
)
|
|
try:
|
|
payload = json.loads(rendered)
|
|
except ValueError as e:
|
|
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON invalid json") from e
|
|
if not isinstance(payload, dict):
|
|
raise VendorPushError("XIAOMI_PUSH_TEMPLATE_PARAM_JSON must be a json object")
|
|
for key, value in payload.items():
|
|
if not isinstance(key, str) or not isinstance(value, str):
|
|
raise VendorPushError("xiaomi template params must be string key-value pairs")
|
|
if not value.strip() or len(value) > 128:
|
|
raise VendorPushError("xiaomi template param value length must be 1-128")
|
|
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def _oppo_auth_token() -> str:
|
|
cache_key = "oppo"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
return cached
|
|
app_key = _require(settings.OPPO_PUSH_APP_KEY, "OPPO_PUSH_APP_KEY")
|
|
master_secret = _require(settings.OPPO_PUSH_MASTER_SECRET, "OPPO_PUSH_MASTER_SECRET")
|
|
timestamp = str(int(time.time() * 1000))
|
|
sign = hashlib.sha256(f"{app_key}{timestamp}{master_secret}".encode()).hexdigest()
|
|
data = _request_form(
|
|
"POST",
|
|
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
|
data={
|
|
"app_key": app_key,
|
|
"timestamp": timestamp,
|
|
"sign": sign,
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
if int(data.get("code", -1)) != 0:
|
|
raise VendorPushError(f"oppo auth failed: {data}")
|
|
token = (data.get("data") or {}).get("auth_token") or data.get("auth_token")
|
|
if not token:
|
|
raise VendorPushError(f"oppo auth missing auth_token: {data}")
|
|
return _cache_put(cache_key, str(token), 24 * 3600)
|
|
|
|
|
|
def _send_oppo(token: str, title: str, alert: str) -> dict[str, Any]:
|
|
auth_token = _oppo_auth_token()
|
|
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
|
message = {
|
|
"target_type": 2,
|
|
"target_value": token,
|
|
"notification": {
|
|
"app_message_id": f"accessibility_disabled_{uuid.uuid4().hex}",
|
|
"title": title,
|
|
"content": alert,
|
|
"click_action_type": 0,
|
|
"off_line": True,
|
|
"off_line_ttl": ttl_hours,
|
|
"action_parameters": json.dumps(_extras(), ensure_ascii=False),
|
|
},
|
|
}
|
|
data = _request_form(
|
|
"POST",
|
|
settings.OPPO_PUSH_SEND_ENDPOINT,
|
|
data={
|
|
"auth_token": auth_token,
|
|
"message": json.dumps(message, ensure_ascii=False),
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
if int(data.get("code", -1)) != 0:
|
|
raise VendorPushError(f"oppo push failed: {data}")
|
|
return data
|