"""厂商直推集成。 服务端不再经由 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