8adad30ff2
- 新增 device 表 + /api/v1/device/{register,heartbeat} + 迁移 device_table
- heartbeat_monitor_worker 周期扫描心跳超时(App 被杀/无障碍停)→ 服务器终端打印告警
(推送本期未接,先用 logger 终端打印代替真实通知;integrations/jpush.py 已备,后续直接替换)
- config / .env.example 增 JPUSH_* / HEARTBEAT_*
- 见 spec(仓库外 e:\codes\spec\accessibility-liveness-push.md)
注:本提交同时快照了并行 session 未提交的 coupon_state 改动 + 相关 migration
(与本功能同迁移链耦合,无法单独拆分,经确认一并提交)。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
95 lines
2.9 KiB
Python
95 lines
2.9 KiB
Python
"""极光推送 JPush(无障碍保护掉线告警)。
|
|
|
|
调极光 Push REST v3 /v3/push 给指定 registration_id 推一条通知。鉴权复用极光 Basic Auth
|
|
(appKey:masterSecret),与一键登录/短信同模式(见 integrations/jiguang.py、sms.py)。
|
|
|
|
凭证:settings.jpush_app_key / jpush_master_secret(JPUSH_* 留空时自动回退到 JG_*,
|
|
若推送与一键登录是同一极光应用)。客户端 push appkey 已知 = 966b451a8d9cfe12d173ea9d。
|
|
厂商通道(App 被杀也能到达)由极光后台 + 客户端插件负责,本服务只管调 push 接口。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("shagua.jpush")
|
|
|
|
|
|
class JPushError(Exception):
|
|
"""极光推送调用失败。"""
|
|
|
|
|
|
class JPushNotConfiguredError(JPushError):
|
|
"""缺 appKey / masterSecret。"""
|
|
|
|
|
|
def push_to_registration_ids(
|
|
registration_ids: list[str],
|
|
*,
|
|
title: str,
|
|
alert: str,
|
|
extras: dict | None = None,
|
|
) -> dict:
|
|
"""给一批 registration_id 推送通知 + 透传消息。失败抛 JPushError。
|
|
|
|
Returns: 极光响应 JSON(含 sendno / msg_id)。
|
|
"""
|
|
if not settings.jpush_configured:
|
|
raise JPushNotConfiguredError(
|
|
"JPush 未配置(缺 JPUSH_APP_KEY/JPUSH_MASTER_SECRET,且 JG_* 也为空)"
|
|
)
|
|
reg_ids = [r for r in registration_ids if r]
|
|
if not reg_ids:
|
|
raise JPushError("registration_ids 为空")
|
|
|
|
auth_b64 = base64.b64encode(
|
|
f"{settings.jpush_app_key}:{settings.jpush_master_secret}".encode()
|
|
).decode()
|
|
payload = {
|
|
"platform": ["android"],
|
|
"audience": {"registration_id": reg_ids},
|
|
"notification": {
|
|
"android": {
|
|
"alert": alert,
|
|
"title": title,
|
|
"priority": 1,
|
|
"extras": extras or {},
|
|
},
|
|
},
|
|
"message": {
|
|
"msg_content": alert,
|
|
"title": title,
|
|
"content_type": "text",
|
|
"extras": extras or {},
|
|
},
|
|
"options": {"time_to_live": 86400, "apns_production": True},
|
|
}
|
|
|
|
try:
|
|
resp = httpx.post(
|
|
settings.JPUSH_PUSH_ENDPOINT,
|
|
json=payload,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"Authorization": f"Basic {auth_b64}",
|
|
},
|
|
timeout=settings.JG_REQUEST_TIMEOUT_SEC,
|
|
)
|
|
except httpx.HTTPError as e:
|
|
raise JPushError(f"jpush 网络错误: {e}") from e
|
|
|
|
if resp.status_code != 200:
|
|
body = resp.text[:300]
|
|
logger.error("[JPush] http=%s body=%s", resp.status_code, body)
|
|
raise JPushError(f"jpush http {resp.status_code}")
|
|
|
|
data = resp.json()
|
|
if not data.get("sendno") and not data.get("msg_id"):
|
|
logger.error("[JPush] unexpected response: %s", data)
|
|
raise JPushError(f"jpush 响应异常: {data}")
|
|
return data
|