forked from erp-dev/erp
fix: appversions
This commit is contained in:
@@ -2,6 +2,7 @@ import logging
|
||||
import json
|
||||
|
||||
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
|
||||
from notifier.jpush import send_jpush_payload
|
||||
from notifier.models import NotifierChannelEnum
|
||||
from notifier.message_api import send_message_api_news_to_agents, send_message_api_text_message
|
||||
from notifier.serializers import (
|
||||
@@ -132,3 +133,144 @@ class MessageAPINotifierBackend(BaseNotifierBackend):
|
||||
}
|
||||
logger.info("[notifier.backends] message api news sent: notifier_id=%s result=%s", notifier.id, result)
|
||||
return result
|
||||
|
||||
|
||||
class JPushNotifierBackend(BaseNotifierBackend):
|
||||
channel = NotifierChannelEnum.JPUSH
|
||||
|
||||
def _load_rendered_payload(self, *, content: str) -> dict:
|
||||
content = (content or "").strip()
|
||||
if not content:
|
||||
raise ValueError("极光推送模板渲染结果不能为空")
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"极光推送模板渲染结果不是合法 JSON: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("极光推送模板渲染结果必须是 JSON 对象")
|
||||
return payload
|
||||
|
||||
def _resolve_aliases(self, *, notifier, rendered_payload: dict, context: dict) -> list[str]:
|
||||
aliases = rendered_payload.get("aliases") or notifier.get_config_value("aliases")
|
||||
if aliases:
|
||||
return self._normalize_aliases(aliases)
|
||||
|
||||
recipient_source = str(
|
||||
rendered_payload.get("recipient_source")
|
||||
or notifier.get_config_value("recipient_source", "participants")
|
||||
or "participants"
|
||||
)
|
||||
employee_ids = []
|
||||
if recipient_source == "participants":
|
||||
employee_ids = context.get("participant_ids") or []
|
||||
elif recipient_source == "creator":
|
||||
employee_ids = [context.get("creator_id")]
|
||||
elif recipient_source == "participants_and_creator":
|
||||
employee_ids = [*(context.get("participant_ids") or []), context.get("creator_id")]
|
||||
else:
|
||||
raise ValueError(f"不支持的极光接收人来源: {recipient_source}")
|
||||
|
||||
alias_prefix = str(notifier.get_config_value("alias_prefix", "emp_") or "emp_")
|
||||
resolved = []
|
||||
for employee_id in employee_ids:
|
||||
if employee_id is None or employee_id == "":
|
||||
continue
|
||||
resolved.append(f"{alias_prefix}{int(employee_id)}")
|
||||
return self._normalize_aliases(resolved)
|
||||
|
||||
def _normalize_aliases(self, values) -> list[str]:
|
||||
aliases = []
|
||||
for value in values or []:
|
||||
alias = str(value or "").strip()
|
||||
if not alias:
|
||||
raise ValueError("极光 aliases 中不能包含空值")
|
||||
if len(alias.encode("utf-8")) > 40:
|
||||
raise ValueError(f"极光 alias 超过 40 字节限制: {alias}")
|
||||
aliases.append(alias)
|
||||
aliases = list(dict.fromkeys(aliases))
|
||||
if not aliases:
|
||||
raise ValueError("极光推送接收 alias 不能为空")
|
||||
if len(aliases) > 1000:
|
||||
raise ValueError("极光单次推送 alias 不能超过 1000 个")
|
||||
return aliases
|
||||
|
||||
def _build_payload(self, *, notifier, rendered_payload: dict, context: dict) -> dict:
|
||||
aliases = self._resolve_aliases(
|
||||
notifier=notifier,
|
||||
rendered_payload=rendered_payload,
|
||||
context=context,
|
||||
)
|
||||
platform = rendered_payload.get("platform") or notifier.get_config_value("platform", "all")
|
||||
title = str(rendered_payload.get("title") or notifier.get_config_value("title", "任务提醒")).strip()
|
||||
alert = str(rendered_payload.get("alert") or rendered_payload.get("content") or "").strip()
|
||||
if not alert:
|
||||
raise ValueError("极光推送 alert 不能为空")
|
||||
|
||||
extras = rendered_payload.get("extras") or {}
|
||||
if not isinstance(extras, dict):
|
||||
raise ValueError("极光推送 extras 必须是 JSON 对象")
|
||||
|
||||
notification = {
|
||||
"alert": alert,
|
||||
"android": {
|
||||
"alert": alert,
|
||||
"title": title,
|
||||
"extras": extras,
|
||||
},
|
||||
"ios": {
|
||||
"alert": alert,
|
||||
"sound": rendered_payload.get("ios_sound") or notifier.get_config_value("ios_sound", "default"),
|
||||
"extras": extras,
|
||||
},
|
||||
}
|
||||
android_channel_id = rendered_payload.get("android_channel_id") or notifier.get_config_value("android_channel_id")
|
||||
if android_channel_id:
|
||||
notification["android"]["channel_id"] = str(android_channel_id)
|
||||
android_intent = rendered_payload.get("android_intent") or notifier.get_config_value("android_intent")
|
||||
if android_intent:
|
||||
notification["android"]["intent"] = {"url": str(android_intent)}
|
||||
android_category = rendered_payload.get("android_category") or notifier.get_config_value("android_category")
|
||||
if android_category:
|
||||
notification["android"]["category"] = str(android_category)
|
||||
|
||||
payload = {
|
||||
"platform": platform,
|
||||
"audience": {"alias": aliases},
|
||||
"notification": notification,
|
||||
"options": {
|
||||
"apns_production": bool(notifier.get_config_value("apns_production", False)),
|
||||
},
|
||||
}
|
||||
time_to_live = rendered_payload.get("time_to_live", notifier.get_config_value("time_to_live"))
|
||||
if time_to_live is not None:
|
||||
payload["options"]["time_to_live"] = int(time_to_live)
|
||||
|
||||
message = rendered_payload.get("message")
|
||||
if message:
|
||||
if not isinstance(message, dict):
|
||||
raise ValueError("极光推送 message 必须是 JSON 对象")
|
||||
payload["message"] = message
|
||||
return payload
|
||||
|
||||
def notify(self, *, notifier, content: str, context: dict) -> dict:
|
||||
rendered_payload = self._load_rendered_payload(content=content)
|
||||
payload = self._build_payload(
|
||||
notifier=notifier,
|
||||
rendered_payload=rendered_payload,
|
||||
context=context,
|
||||
)
|
||||
response = send_jpush_payload(
|
||||
app_key=notifier.get_config_value("app_key"),
|
||||
master_secret=notifier.get_config_value("master_secret"),
|
||||
payload=payload,
|
||||
timeout_seconds=float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0),
|
||||
)
|
||||
result = {
|
||||
"channel": self.channel,
|
||||
"aliases": payload["audience"]["alias"],
|
||||
"msg_id": response.raw.get("msg_id"),
|
||||
"sendno": response.raw.get("sendno"),
|
||||
"raw": response.raw,
|
||||
}
|
||||
logger.info("[notifier.backends] jpush sent: notifier_id=%s result=%s", notifier.id, result)
|
||||
return result
|
||||
|
||||
81
notifier/jpush.py
Normal file
81
notifier/jpush.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
JPUSH_PUSH_URL = "https://api.jpush.cn/v3/push"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JPushResponse:
|
||||
status_code: int
|
||||
raw: dict
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return 200 <= int(self.status_code) < 300 and "error" not in self.raw
|
||||
|
||||
|
||||
def _build_basic_auth_header(*, app_key: str, master_secret: str) -> str:
|
||||
token = f"{app_key}:{master_secret}".encode("utf-8")
|
||||
return "Basic " + base64.b64encode(token).decode("ascii")
|
||||
|
||||
|
||||
def send_jpush_payload(
|
||||
*,
|
||||
app_key: str,
|
||||
master_secret: str,
|
||||
payload: dict,
|
||||
timeout_seconds: float = 10.0,
|
||||
) -> JPushResponse:
|
||||
app_key = str(app_key or "").strip()
|
||||
master_secret = str(master_secret or "").strip()
|
||||
if not app_key:
|
||||
raise ValueError("JPush app_key 未配置")
|
||||
if not master_secret:
|
||||
raise ValueError("JPush master_secret 未配置")
|
||||
if not isinstance(payload, dict) or not payload:
|
||||
raise ValueError("JPush payload 不能为空")
|
||||
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = Request(
|
||||
url=JPUSH_PUSH_URL,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": _build_basic_auth_header(
|
||||
app_key=app_key,
|
||||
master_secret=master_secret,
|
||||
),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urlopen(req, timeout=float(timeout_seconds)) as resp:
|
||||
body = resp.read().decode("utf-8", errors="replace")
|
||||
status_code = int(getattr(resp, "status", 200) or 200)
|
||||
except HTTPError as exc:
|
||||
body = ""
|
||||
try:
|
||||
body = exc.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
raw = json.loads(body) if body else {}
|
||||
except Exception:
|
||||
raw = {"error": {"message": body}}
|
||||
raise RuntimeError(f"JPush HTTPError: status={exc.code}, body={raw}") from exc
|
||||
except URLError as exc:
|
||||
raise RuntimeError(f"JPush URLError: {exc}") from exc
|
||||
|
||||
try:
|
||||
raw = json.loads(body) if body else {}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"JPush 响应不是合法 JSON: {body}") from exc
|
||||
|
||||
return JPushResponse(status_code=status_code, raw=raw)
|
||||
25
notifier/migrations/0004_alter_notifier_channel.py
Normal file
25
notifier/migrations/0004_alter_notifier_channel.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("notifier", "0003_alter_notifier_channel_alter_notifierroute_event_key"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="notifier",
|
||||
name="channel",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("wecom_webhook", "企业微信机器人"),
|
||||
("message_api", "消息发送 API"),
|
||||
("jpush", "极光推送"),
|
||||
],
|
||||
default="wecom_webhook",
|
||||
max_length=50,
|
||||
verbose_name="通知渠道",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -8,6 +8,7 @@ from flower.common import ModelBase
|
||||
class NotifierChannelEnum(models.TextChoices):
|
||||
WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人"
|
||||
MESSAGE_API = "message_api", "消息发送 API"
|
||||
JPUSH = "jpush", "极光推送"
|
||||
|
||||
|
||||
class NotificationEventKeyEnum(models.TextChoices):
|
||||
@@ -41,7 +42,7 @@ class Notifier(ModelBase):
|
||||
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
|
||||
|
||||
def get_template_name(self) -> str:
|
||||
suffix = "json" if self.channel == NotifierChannelEnum.MESSAGE_API else "md"
|
||||
suffix = "json" if self.channel in {NotifierChannelEnum.MESSAGE_API, NotifierChannelEnum.JPUSH} else "md"
|
||||
return f"notifier/events/{self.template_key}.{suffix}"
|
||||
|
||||
def get_config_value(self, key: str, default=None):
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from notifier.backends import MessageAPINotifierBackend, WeComWebhookNotifierBackend
|
||||
from notifier.backends import JPushNotifierBackend, MessageAPINotifierBackend, WeComWebhookNotifierBackend
|
||||
from notifier.models import NotifierChannelEnum
|
||||
|
||||
|
||||
BACKEND_REGISTRY = {
|
||||
NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend,
|
||||
NotifierChannelEnum.MESSAGE_API: MessageAPINotifierBackend,
|
||||
NotifierChannelEnum.JPUSH: JPushNotifierBackend,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notified_at) -> dict[str, Any]:
|
||||
participant_names = list(
|
||||
mission.get_participants().values_list("employee__name", flat=True)
|
||||
)
|
||||
participants = list(mission.get_participants().values_list("employee_id", "employee__name"))
|
||||
participant_ids = [employee_id for employee_id, _ in participants]
|
||||
participant_names = [employee_name for _, employee_name in participants]
|
||||
content_type = getattr(mission.content_type, "model", None)
|
||||
next_count = mission.unreplied_notify_sent_count + 1
|
||||
payload = {
|
||||
@@ -32,6 +32,7 @@ def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notifie
|
||||
"is_cancelled": mission.is_cancelled,
|
||||
"creator_id": mission.creator_id,
|
||||
"creator_name": getattr(mission.creator, "name", ""),
|
||||
"participant_ids": participant_ids,
|
||||
"participant_names": participant_names,
|
||||
"participant_names_display": "、".join(participant_names) if participant_names else "无",
|
||||
"content_type": content_type or "",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "新任务",
|
||||
"alert": "你有一个新任务:{{ description|truncatechars:40|escapejs }}",
|
||||
"extras": {
|
||||
"event_key": "mission.created",
|
||||
"mission_id": "{{ mission_id }}",
|
||||
"merchant_id": "{{ merchant_id }}",
|
||||
"route": "/missions/{{ mission_id }}"
|
||||
}
|
||||
}
|
||||
@@ -555,6 +555,68 @@ class NotifierServiceTestCase(TestCase):
|
||||
notifier=notifier,
|
||||
payload={"mission_id": 12},
|
||||
)
|
||||
|
||||
@patch("notifier.backends.send_jpush_payload")
|
||||
def test_send_notification_with_notifier_uses_jpush_backend(self, mock_send):
|
||||
mock_send.return_value.raw = {"sendno": "123", "msg_id": "456"}
|
||||
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务创建极光推送",
|
||||
channel=NotifierChannelEnum.JPUSH,
|
||||
template_key="mission_created_jpush",
|
||||
config={
|
||||
"app_key": "app-key-1",
|
||||
"master_secret": "master-secret-1",
|
||||
"alias_prefix": "emp_",
|
||||
"recipient_source": "participants",
|
||||
"platform": "all",
|
||||
"apns_production": True,
|
||||
"android_channel_id": "mission",
|
||||
},
|
||||
)
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"merchant_id": self.merchant.id,
|
||||
"description": "检查打印质量",
|
||||
"participant_ids": [21, 22, 21],
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["channel"], NotifierChannelEnum.JPUSH)
|
||||
self.assertEqual(result["aliases"], ["emp_21", "emp_22"])
|
||||
mock_send.assert_called_once()
|
||||
self.assertEqual(mock_send.call_args.kwargs["app_key"], "app-key-1")
|
||||
self.assertEqual(mock_send.call_args.kwargs["master_secret"], "master-secret-1")
|
||||
payload = mock_send.call_args.kwargs["payload"]
|
||||
self.assertEqual(payload["audience"], {"alias": ["emp_21", "emp_22"]})
|
||||
self.assertEqual(payload["notification"]["android"]["channel_id"], "mission")
|
||||
self.assertEqual(payload["notification"]["ios"]["sound"], "default")
|
||||
self.assertTrue(payload["options"]["apns_production"])
|
||||
self.assertEqual(payload["notification"]["android"]["extras"]["mission_id"], "12")
|
||||
|
||||
@patch("notifier.backends.send_jpush_payload")
|
||||
def test_send_notification_with_notifier_rejects_jpush_without_aliases(self, mock_send):
|
||||
notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="无接收人极光推送",
|
||||
channel=NotifierChannelEnum.JPUSH,
|
||||
template_key="mission_created_jpush",
|
||||
config={"app_key": "app-key-1", "master_secret": "master-secret-1"},
|
||||
)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "alias 不能为空"):
|
||||
send_notification_with_notifier(
|
||||
notifier=notifier,
|
||||
payload={"mission_id": 12, "description": "检查打印质量"},
|
||||
)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
|
||||
class MessageAPIServiceTestCase(TestCase):
|
||||
@patch("notifier.message_api.urlopen")
|
||||
def test_send_message_api_text_message_calls_message_api_endpoint(self, mock_urlopen):
|
||||
|
||||
Reference in New Issue
Block a user