125 lines
3.3 KiB
Python
125 lines
3.3 KiB
Python
|
|
"""浏览器推送通知服务 — Web Push Protocol (RFC 8291)"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
from app.core.database import SessionLocal
|
|||
|
|
from app.models.push_subscription import PushSubscription
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def send_push_to_user(
|
|||
|
|
user_id: str,
|
|||
|
|
title: str,
|
|||
|
|
body: str = "",
|
|||
|
|
url: str = "/",
|
|||
|
|
require_interaction: bool = False,
|
|||
|
|
) -> int:
|
|||
|
|
"""向指定用户的所有设备发送推送通知。返回成功发送数。"""
|
|||
|
|
db = SessionLocal()
|
|||
|
|
sent = 0
|
|||
|
|
try:
|
|||
|
|
subs = (
|
|||
|
|
db.query(PushSubscription)
|
|||
|
|
.filter(PushSubscription.user_id == user_id)
|
|||
|
|
.all()
|
|||
|
|
)
|
|||
|
|
for sub in subs:
|
|||
|
|
ok = await _send_webpush(
|
|||
|
|
endpoint=sub.endpoint,
|
|||
|
|
p256dh=sub.p256dh,
|
|||
|
|
auth=sub.auth,
|
|||
|
|
title=title,
|
|||
|
|
body=body,
|
|||
|
|
url=url,
|
|||
|
|
require_interaction=require_interaction,
|
|||
|
|
)
|
|||
|
|
if ok:
|
|||
|
|
sent += 1
|
|||
|
|
return sent
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def broadcast_push(
|
|||
|
|
title: str,
|
|||
|
|
body: str = "",
|
|||
|
|
url: str = "/",
|
|||
|
|
) -> int:
|
|||
|
|
"""向所有已订阅用户广播推送通知。"""
|
|||
|
|
db = SessionLocal()
|
|||
|
|
sent = 0
|
|||
|
|
try:
|
|||
|
|
subs = db.query(PushSubscription).all()
|
|||
|
|
for sub in subs:
|
|||
|
|
ok = await _send_webpush(
|
|||
|
|
endpoint=sub.endpoint,
|
|||
|
|
p256dh=sub.p256dh,
|
|||
|
|
auth=sub.auth,
|
|||
|
|
title=title,
|
|||
|
|
body=body,
|
|||
|
|
url=url,
|
|||
|
|
require_interaction=False,
|
|||
|
|
)
|
|||
|
|
if ok:
|
|||
|
|
sent += 1
|
|||
|
|
return sent
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _send_webpush(
|
|||
|
|
endpoint: str,
|
|||
|
|
p256dh: str,
|
|||
|
|
auth: str,
|
|||
|
|
title: str,
|
|||
|
|
body: str,
|
|||
|
|
url: str = "/",
|
|||
|
|
require_interaction: bool = False,
|
|||
|
|
) -> bool:
|
|||
|
|
"""发送单条 Web Push 消息。使用 pywebpush 或直接 HTTP POST。"""
|
|||
|
|
payload = json.dumps({
|
|||
|
|
"title": title,
|
|||
|
|
"body": body,
|
|||
|
|
"data": {"url": url},
|
|||
|
|
"requireInteraction": require_interaction,
|
|||
|
|
"icon": "/icons/icon-192.png",
|
|||
|
|
"badge": "/icons/icon-192.png",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 尝试 pywebpush(如果已安装)
|
|||
|
|
try:
|
|||
|
|
from pywebpush import WebPusher, WebPushException
|
|||
|
|
wp = WebPusher({
|
|||
|
|
"endpoint": endpoint,
|
|||
|
|
"keys": {"p256dh": p256dh, "auth": auth},
|
|||
|
|
})
|
|||
|
|
wp.send(payload, timeout=10)
|
|||
|
|
return True
|
|||
|
|
except ImportError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# Fallback: 直接 HTTP POST(不加密,仅用于开发环境)
|
|||
|
|
import aiohttp
|
|||
|
|
async with aiohttp.ClientSession() as session:
|
|||
|
|
async with session.post(
|
|||
|
|
endpoint,
|
|||
|
|
data=payload,
|
|||
|
|
headers={
|
|||
|
|
"Content-Type": "application/json",
|
|||
|
|
"TTL": "86400",
|
|||
|
|
},
|
|||
|
|
timeout=aiohttp.ClientTimeout(total=10),
|
|||
|
|
) as resp:
|
|||
|
|
ok = resp.status in (200, 201, 204)
|
|||
|
|
if not ok:
|
|||
|
|
logger.warning("Push 发送失败: status=%d", resp.status)
|
|||
|
|
return ok
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("Push 发送异常: %s", e)
|
|||
|
|
return False
|