d6016c12f9
2024-11-20 后创建的 OPPO 应用发送通知必须携带 category(新规), 配置项 OPPO_PUSH_CHANNEL_ID / OPPO_PUSH_CATEGORY / OPPO_PUSH_NOTIFY_LEVEL(0=不传), 均留空时 payload 与原先完全一致。凭据已到位:荣耀/小米(含 channel 154219)/OPPO/vivo 四家配齐(.env,不入库),仅剩华为待补。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
520 lines
18 KiB
Python
520 lines
18 KiB
Python
"""厂商直推集成(荣耀 / 华为 / 小米 / OPPO / vivo)。
|
|
|
|
服务端不经由 JPush Push API,而是按客户端上报的 push_vendor + push_token
|
|
分发到各手机厂商的服务端 API。
|
|
|
|
对外两个入口:
|
|
- send_notification() 通用:任意标题/正文/extras(消息中心 13 类推送走这里),
|
|
mock=True 时不真调厂商、返回渲染结果(虚拟数据联调用)
|
|
- send_accessibility_disabled() 旧:无障碍掉线召回(heartbeat_monitor_worker 在用),
|
|
已改为 send_notification 的薄封装,行为不变
|
|
|
|
各厂商鉴权方式:荣耀/华为 OAuth client_credentials 换 access_token(进程内缓存);
|
|
vivo/OPPO 签名换 authToken(缓存 24h);小米直接 AppSecret 走 Authorization 头。
|
|
"""
|
|
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", "huawei", "vivo", "xiaomi", "oppo"})
|
|
|
|
# vendor key → 中文名(测试/配置状态接口展示用)
|
|
VENDOR_LABELS: dict[str, str] = {
|
|
"honor": "荣耀",
|
|
"huawei": "华为",
|
|
"xiaomi": "小米",
|
|
"oppo": "OPPO",
|
|
"vivo": "vivo",
|
|
}
|
|
|
|
# 各厂商真发推送所需的 settings 键(缺任一即视为未配置;/api/v1/push/vendors 据此报缺)
|
|
REQUIRED_SETTINGS: dict[str, tuple[str, ...]] = {
|
|
"honor": ("HONOR_PUSH_APP_ID", "HONOR_PUSH_CLIENT_ID", "HONOR_PUSH_CLIENT_SECRET"),
|
|
"huawei": ("HUAWEI_PUSH_APP_ID", "HUAWEI_PUSH_APP_SECRET"),
|
|
"xiaomi": ("XIAOMI_PUSH_APP_SECRET",),
|
|
"oppo": ("OPPO_PUSH_APP_KEY", "OPPO_PUSH_MASTER_SECRET"),
|
|
"vivo": ("VIVO_PUSH_APP_ID", "VIVO_PUSH_APP_KEY", "VIVO_PUSH_APP_SECRET"),
|
|
}
|
|
|
|
|
|
def missing_settings(vendor: str) -> list[str]:
|
|
"""该厂商还缺哪些配置键(全配齐返回空列表)。vendor 需已 normalize。"""
|
|
return [key for key in REQUIRED_SETTINGS.get(vendor, ()) if not getattr(settings, key, "")]
|
|
|
|
|
|
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",
|
|
"hms": "huawei",
|
|
"华为": "huawei",
|
|
"harmony": "huawei",
|
|
"harmonyos": "huawei",
|
|
"mi": "xiaomi",
|
|
"小米": "xiaomi",
|
|
"oneplus": "oppo",
|
|
"realme": "oppo",
|
|
}
|
|
return aliases.get(vendor, vendor)
|
|
|
|
|
|
def send_notification(
|
|
push_vendor: str,
|
|
push_token: str,
|
|
*,
|
|
title: str,
|
|
body: str,
|
|
extras: dict[str, str] | None = None,
|
|
mock: bool = False,
|
|
) -> dict[str, Any]:
|
|
"""按厂商 token 向单台设备发送一条通知(通用入口)。
|
|
|
|
- extras:透传给客户端的自定义键值(值统一 string,兼容各厂商限制)。消息中心推送约定
|
|
至少带 {"type": <13 种类型 key>, "notificationId": <站内消息 id>},客户端据此
|
|
深链落地 + 调 /notifications/read 同步置读(PRD §4 push 联动)。
|
|
- mock=True:不真调厂商 API,校验参数后原样返回渲染结果(虚拟数据阶段联调/自动化测试用)。
|
|
"""
|
|
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")
|
|
extras = {str(k): str(v) for k, v in (extras or {}).items()}
|
|
|
|
if mock:
|
|
logger.info(
|
|
"[mock push] vendor=%s token=%s... title=%s body=%s extras=%s",
|
|
vendor, token[:12], title, body, extras,
|
|
)
|
|
return {
|
|
"mock": True,
|
|
"vendor": vendor,
|
|
"title": title,
|
|
"body": body,
|
|
"extras": extras,
|
|
}
|
|
|
|
dispatch: dict[str, Callable[[str, str, str, dict[str, str]], dict[str, Any]]] = {
|
|
"honor": _send_honor,
|
|
"huawei": _send_huawei,
|
|
"vivo": _send_vivo,
|
|
"xiaomi": _send_xiaomi,
|
|
"oppo": _send_oppo,
|
|
}
|
|
return dispatch[vendor](token, title, body, extras)
|
|
|
|
|
|
def send_accessibility_disabled(
|
|
push_vendor: str,
|
|
push_token: str,
|
|
*,
|
|
title: str = "保护已关闭",
|
|
alert: str = "傻瓜比价的无障碍保护被关了,点此重新开启,继续帮你自动比价省钱。",
|
|
) -> dict[str, Any]:
|
|
"""按厂商 token 向单台设备发送无障碍掉线通知(heartbeat_monitor_worker 在用,行为不变)。"""
|
|
return send_notification(
|
|
push_vendor,
|
|
push_token,
|
|
title=title,
|
|
body=alert,
|
|
extras={"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, body: str, extras: dict[str, 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": body},
|
|
"android": {
|
|
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
|
"targetUserType": 1,
|
|
"notification": {
|
|
"title": title,
|
|
"body": body,
|
|
"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 _huawei_access_token() -> str:
|
|
"""华为 OAuth2 client_credentials 换 access_token(client_id 即 AGC 应用的 AppId)。"""
|
|
cache_key = "huawei"
|
|
cached = _cache_get(cache_key)
|
|
if cached:
|
|
return cached
|
|
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
|
app_secret = _require(settings.HUAWEI_PUSH_APP_SECRET, "HUAWEI_PUSH_APP_SECRET")
|
|
data = _request_form(
|
|
"POST",
|
|
settings.HUAWEI_PUSH_TOKEN_ENDPOINT,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": app_id,
|
|
"client_secret": app_secret,
|
|
},
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
)
|
|
token = data.get("access_token")
|
|
if not token:
|
|
raise VendorPushError(f"huawei auth failed: {data}")
|
|
return _cache_put(cache_key, str(token), data.get("expires_in"))
|
|
|
|
|
|
def _send_huawei(token: str, title: str, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
|
"""华为 Push Kit 下行消息(v1 messages:send)。成功码 '80000000';
|
|
'80100000' 为部分成功(单 token 场景仍视为失败,错误里带原始响应便于排障)。"""
|
|
app_id = _require(settings.HUAWEI_PUSH_APP_ID, "HUAWEI_PUSH_APP_ID")
|
|
access_token = _huawei_access_token()
|
|
payload = {
|
|
"validate_only": False,
|
|
"message": {
|
|
"data": json.dumps(extras, ensure_ascii=False),
|
|
"android": {
|
|
"ttl": f"{settings.PUSH_TIME_TO_LIVE_SEC}s",
|
|
"notification": {
|
|
"title": title,
|
|
"body": body,
|
|
"click_action": {"type": 3},
|
|
"importance": "NORMAL",
|
|
},
|
|
},
|
|
"token": [token],
|
|
},
|
|
}
|
|
data = _request_json(
|
|
"POST",
|
|
settings.HUAWEI_PUSH_SEND_ENDPOINT_TEMPLATE.format(app_id=app_id),
|
|
json=payload,
|
|
headers={
|
|
"Content-Type": "application/json; charset=UTF-8",
|
|
"Authorization": f"Bearer {access_token}",
|
|
},
|
|
)
|
|
if str(data.get("code", "")) != "80000000":
|
|
raise VendorPushError(f"huawei 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, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
|
app_id = _require(settings.VIVO_PUSH_APP_ID, "VIVO_PUSH_APP_ID")
|
|
auth_token = _vivo_auth_token()
|
|
payload: dict[str, Any] = {
|
|
"appId": app_id,
|
|
"regId": token,
|
|
"notifyType": settings.VIVO_PUSH_NOTIFY_TYPE,
|
|
"title": title,
|
|
"content": body,
|
|
"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:
|
|
payload["category"] = settings.VIVO_PUSH_CATEGORY
|
|
data = _request_json(
|
|
"POST",
|
|
settings.VIVO_PUSH_SEND_ENDPOINT,
|
|
json=payload,
|
|
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, body: str, extras: dict[str, 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 body
|
|
form = {
|
|
"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:
|
|
form["extra.channel_id"] = settings.XIAOMI_PUSH_CHANNEL_ID.strip()
|
|
if settings.XIAOMI_PUSH_TEMPLATE_ID:
|
|
form["extra.template_id"] = settings.XIAOMI_PUSH_TEMPLATE_ID.strip()
|
|
if settings.XIAOMI_PUSH_TEMPLATE_PARAM_JSON:
|
|
form["extra.template_param"] = _xiaomi_template_param(title, body)
|
|
data = _request_form(
|
|
"POST",
|
|
settings.XIAOMI_PUSH_SEND_ENDPOINT,
|
|
data=form,
|
|
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, body: str, extras: dict[str, str]) -> dict[str, Any]:
|
|
auth_token = _oppo_auth_token()
|
|
ttl_hours = max(1, min(72, settings.PUSH_TIME_TO_LIVE_SEC // 3600))
|
|
notification: dict[str, Any] = {
|
|
"app_message_id": f"{extras.get('type', 'notify')}_{uuid.uuid4().hex}",
|
|
"title": title,
|
|
"content": body,
|
|
"click_action_type": 0,
|
|
"off_line": True,
|
|
"off_line_ttl": ttl_hours,
|
|
"action_parameters": json.dumps(extras, ensure_ascii=False),
|
|
}
|
|
# 新消息分类(2024-11-20 后创建的 OPPO 应用必须带 category,否则可能被拒收/降级)
|
|
if settings.OPPO_PUSH_CHANNEL_ID.strip():
|
|
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID.strip()
|
|
if settings.OPPO_PUSH_CATEGORY.strip():
|
|
notification["category"] = settings.OPPO_PUSH_CATEGORY.strip()
|
|
if settings.OPPO_PUSH_NOTIFY_LEVEL:
|
|
notification["notify_level"] = settings.OPPO_PUSH_NOTIFY_LEVEL
|
|
message = {
|
|
"target_type": 2,
|
|
"target_value": token,
|
|
"notification": notification,
|
|
}
|
|
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
|