feat(device): 无障碍保护存活心跳检测 + 掉线终端告警
- 新增 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>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
"""极光推送 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
|
||||
Reference in New Issue
Block a user