"""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 message = { "target_type": _TARGET_TYPE_REGISTRATION_ID, "target_value": registration_id, "notification": { "title": title, "content": content, "click_action_type": click_action_type, }, } # 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 重试后仍失败")