0921cb84d9
- unicast notification 带 channel_id(默认 push_oplus_category_content, OPPO 系统内容渠道) - category 做成 config 可配(默认空); 升级 service 渠道+ACCOUNT 分类只改配置不改代码 - 客户端无需改动(OPPO 系统预置渠道 App 不自建) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 lines
6.9 KiB
Python
184 lines
6.9 KiB
Python
"""OPPO 推送服务端直连(厂商通道)。
|
|
|
|
不经极光, 直接调 OPPO PUSH 服务端 API(api.push.oppomobile.com)。提现审核通过等交易类
|
|
通知走这条。凭证来自 OPPO 开放平台(app_key/master_secret, 见 settings.OPPO_PUSH_*)。
|
|
|
|
两步鉴权(OPPO 协议):
|
|
1. POST /server/v1/auth 用 sign=sha256(app_key+timestamp+master_secret) 换 auth_token
|
|
(有效期 24h)。token 进程内缓存复用, 到期前自动重取。
|
|
2. POST /server/v1/message/notification/unicast header 带 auth_token, 按单个
|
|
registration_id(OPPO 的 registerID) 定向推送一条通知栏消息。
|
|
|
|
发送 best-effort: 未配凭证 / 网络失败 / OPPO 返错都抛 OppoPushError, 由调用方(push_notify)
|
|
吞掉只记日志, 绝不连累审核/打款主流程。风格对齐 integrations/sms.py(httpx 同步 + 未配降级)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from threading import Lock
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("shagua.oppo_push")
|
|
|
|
# OPPO target_type 枚举: 2 = 按 registration_id 单推
|
|
_TARGET_TYPE_REGISTRATION_ID = 2
|
|
# auth_token 官方有效期 24h; 提前 1h 视为过期重取, 留刷新余量(避免边界请求踩到刚过期)
|
|
_TOKEN_TTL_SEC = 24 * 3600
|
|
_TOKEN_REFRESH_MARGIN_SEC = 3600
|
|
|
|
|
|
class OppoPushError(Exception):
|
|
"""OPPO 推送失败(未配置 / 鉴权失败 / 网络错误 / OPPO 返错)。调用方 best-effort 吞。"""
|
|
|
|
|
|
@dataclass
|
|
class _CachedToken:
|
|
token: str
|
|
expires_at: float # epoch 秒(= 取到 token 的时刻 + 24h)
|
|
|
|
|
|
# 进程内存 token 缓存(单 worker 有效; 多 worker 各自缓存, 各自换 token, OPPO 侧无副作用)
|
|
_token_cache: _CachedToken | None = None
|
|
_token_lock = Lock()
|
|
|
|
|
|
def _sign(app_key: str, timestamp_ms: int, master_secret: str) -> str:
|
|
"""OPPO 鉴权签名: sha256(app_key + timestamp + master_secret) 十六进制小写。"""
|
|
raw = f"{app_key}{timestamp_ms}{master_secret}"
|
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _parse_oppo_response(resp: httpx.Response, phase: str) -> dict | None:
|
|
"""解析 OPPO 统一响应 {code, message, data}: code==0 返 data, 否则抛 OppoPushError。"""
|
|
try:
|
|
body = resp.json()
|
|
except Exception as e:
|
|
raise OppoPushError(
|
|
f"OPPO {phase} 响应非 JSON(http={resp.status_code}): {resp.text[:200]}"
|
|
) from e
|
|
if body.get("code") != 0:
|
|
raise OppoPushError(
|
|
f"OPPO {phase} 失败 code={body.get('code')} message={body.get('message')!r}"
|
|
)
|
|
return body.get("data")
|
|
|
|
|
|
def _fetch_auth_token() -> str:
|
|
"""调 /server/v1/auth 换 auth_token。失败抛 OppoPushError。"""
|
|
timestamp_ms = int(time.time() * 1000) # 毫秒时间戳(OPPO 允许 ±10 分钟误差)
|
|
sign = _sign(
|
|
settings.OPPO_PUSH_APP_KEY, timestamp_ms, settings.OPPO_PUSH_MASTER_SECRET
|
|
)
|
|
try:
|
|
resp = httpx.post(
|
|
settings.OPPO_PUSH_AUTH_ENDPOINT,
|
|
data={
|
|
"app_key": settings.OPPO_PUSH_APP_KEY,
|
|
"sign": sign,
|
|
"timestamp": timestamp_ms,
|
|
},
|
|
timeout=settings.OPPO_PUSH_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise OppoPushError(f"OPPO auth 网络错误: {e}") from e
|
|
|
|
data = _parse_oppo_response(resp, "auth")
|
|
token = (data or {}).get("auth_token")
|
|
if not token:
|
|
raise OppoPushError(f"OPPO auth 未返回 auth_token: {resp.text[:200]}")
|
|
return token
|
|
|
|
|
|
def _get_auth_token(force_refresh: bool = False) -> str:
|
|
"""取 auth_token(缓存优先; 到期或 force_refresh 才重取)。线程安全。"""
|
|
global _token_cache
|
|
now = time.time()
|
|
with _token_lock:
|
|
cache = _token_cache
|
|
if (
|
|
not force_refresh
|
|
and cache is not None
|
|
and now < cache.expires_at - _TOKEN_REFRESH_MARGIN_SEC
|
|
):
|
|
return cache.token
|
|
token = _fetch_auth_token()
|
|
_token_cache = _CachedToken(token=token, expires_at=now + _TOKEN_TTL_SEC)
|
|
return token
|
|
|
|
|
|
def send_notification(
|
|
registration_id: str,
|
|
title: str,
|
|
content: str,
|
|
*,
|
|
click_action_type: int | None = None,
|
|
) -> str:
|
|
"""向单个 OPPO registerID 推一条通知栏消息, 返回 OPPO message_id。
|
|
|
|
失败(未配置 / 鉴权 / 网络 / OPPO 返错)一律抛 OppoPushError, 由调用方吞。
|
|
unicast 首次失败时强刷一次 token 重试(覆盖 token 被 OPPO 提前失效的情况); 网络错误不重试。
|
|
"""
|
|
if not settings.OPPO_PUSH_ENABLED:
|
|
raise OppoPushError("OPPO 推送总开关关闭(OPPO_PUSH_ENABLED=false)")
|
|
if not settings.oppo_push_configured:
|
|
raise OppoPushError("OPPO 推送未配置(缺 OPPO_PUSH_APP_KEY/OPPO_PUSH_MASTER_SECRET)")
|
|
if not registration_id:
|
|
raise OppoPushError("registration_id 为空")
|
|
|
|
if click_action_type is None:
|
|
click_action_type = settings.OPPO_PUSH_CLICK_ACTION_TYPE
|
|
notification = {
|
|
"title": title,
|
|
"content": content,
|
|
"click_action_type": click_action_type,
|
|
}
|
|
# channel_id: Android 8+ 通知渠道(客户端已建同名渠道 + OPPO 管理台登记, 三方一致才展示)。
|
|
if settings.OPPO_PUSH_CHANNEL_ID:
|
|
notification["channel_id"] = settings.OPPO_PUSH_CHANNEL_ID
|
|
# category: OPPO 新消息分类(默认空不带; 依赖管理台资质, 见 config 注释)。
|
|
if settings.OPPO_PUSH_CATEGORY:
|
|
notification["category"] = settings.OPPO_PUSH_CATEGORY
|
|
message = {
|
|
"target_type": _TARGET_TYPE_REGISTRATION_ID,
|
|
"target_value": registration_id,
|
|
"notification": notification,
|
|
}
|
|
# OPPO unicast body 是 form 编码, message 字段为 JSON 字符串
|
|
payload = {"message": json.dumps(message, ensure_ascii=False)}
|
|
|
|
last_err: OppoPushError | None = None
|
|
for attempt in range(2):
|
|
token = _get_auth_token(force_refresh=(attempt == 1))
|
|
try:
|
|
resp = httpx.post(
|
|
settings.OPPO_PUSH_UNICAST_ENDPOINT,
|
|
data=payload,
|
|
headers={"auth_token": token},
|
|
timeout=settings.OPPO_PUSH_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
# 网络错误与 token 无关, 不强刷重试
|
|
raise OppoPushError(f"OPPO unicast 网络错误: {e}") from e
|
|
try:
|
|
data = _parse_oppo_response(resp, "unicast")
|
|
except OppoPushError as e:
|
|
last_err = e
|
|
if attempt == 0:
|
|
logger.warning("[OPPO] unicast 失败(%s), 强刷 token 重试一次", e)
|
|
continue
|
|
raise
|
|
message_id = (data or {}).get("message_id", "")
|
|
logger.info(
|
|
"[OPPO] 推送成功 regId=%s… message_id=%s", registration_id[:12], message_id
|
|
)
|
|
return message_id
|
|
# 理论到不了(第二次失败已 raise); 防御性收尾
|
|
raise last_err or OppoPushError("OPPO unicast 重试后仍失败")
|