forked from erp-dev/erp
feat: notifier beta
This commit is contained in:
0
notifier/__init__.py
Normal file
0
notifier/__init__.py
Normal file
20
notifier/admin.py
Normal file
20
notifier/admin.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from notifier.models import Notifier
|
||||
|
||||
|
||||
@admin.register(Notifier)
|
||||
class NotifierAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
"id",
|
||||
"merchant",
|
||||
"name",
|
||||
"event_key",
|
||||
"channel",
|
||||
"template_key",
|
||||
"is_enabled",
|
||||
"created_at",
|
||||
]
|
||||
list_filter = ["merchant", "event_key", "channel", "is_enabled", "created_at"]
|
||||
search_fields = ["name", "template_key", "description"]
|
||||
readonly_fields = ["created_at", "updated_at"]
|
||||
7
notifier/apps.py
Normal file
7
notifier/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotifierConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'notifier'
|
||||
verbose_name = '通知器'
|
||||
38
notifier/backends.py
Normal file
38
notifier/backends.py
Normal file
@@ -0,0 +1,38 @@
|
||||
import logging
|
||||
|
||||
from api_v1.utils.wecom_webhook import send_wecom_webhook_message
|
||||
from notifier.models import NotifierChannelEnum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseNotifierBackend:
|
||||
channel = ""
|
||||
|
||||
def notify(self, *, notifier, content: str, context: dict) -> dict:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class WeComWebhookNotifierBackend(BaseNotifierBackend):
|
||||
channel = NotifierChannelEnum.WECOM_WEBHOOK
|
||||
|
||||
def notify(self, *, notifier, content: str, context: dict) -> dict:
|
||||
msgtype = str(notifier.get_config_value("msgtype", "markdown") or "markdown").strip().lower()
|
||||
timeout_seconds = float(notifier.get_config_value("timeout_seconds", 10.0) or 10.0)
|
||||
key = str(notifier.get_config_value("key", "") or "").strip()
|
||||
response = send_wecom_webhook_message(
|
||||
content=content,
|
||||
msgtype=msgtype,
|
||||
key=key or None,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
if not response.ok:
|
||||
raise RuntimeError(f"WeCom webhook 返回失败: errcode={response.errcode}, errmsg={response.errmsg}")
|
||||
result = {
|
||||
"channel": self.channel,
|
||||
"msgtype": msgtype,
|
||||
"errcode": response.errcode,
|
||||
"errmsg": response.errmsg,
|
||||
}
|
||||
logger.info("[notifier.backends] wecom webhook sent: notifier_id=%s result=%s", notifier.id, result)
|
||||
return result
|
||||
38
notifier/migrations/0001_initial.py
Normal file
38
notifier/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 5.2.8 on 2026-04-10 07:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('basic_info', '0025_customer_uniq_customer_merchant_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notifier',
|
||||
fields=[
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('id', models.BigAutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='通知器名称')),
|
||||
('event_key', models.CharField(choices=[('mission.created', '任务已创建'), ('mission.replied', '任务有新回应'), ('mission.completed', '任务已完成'), ('mission.reply_rejected', '任务回应已撤销'), ('mission.reopened', '任务已重新打开'), ('mission.cancelled', '任务已取消')], db_index=True, max_length=100, verbose_name='事件标识')),
|
||||
('channel', models.CharField(choices=[('wecom_webhook', '企业微信机器人')], default='wecom_webhook', max_length=50, verbose_name='通知渠道')),
|
||||
('template_key', models.CharField(max_length=100, verbose_name='模板标识')),
|
||||
('is_enabled', models.BooleanField(db_index=True, default=True, verbose_name='是否启用')),
|
||||
('config', models.JSONField(blank=True, default=dict, verbose_name='渠道配置')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='备注描述')),
|
||||
('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='notifiers', to='basic_info.merchant', verbose_name='所属商户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '通知器',
|
||||
'verbose_name_plural': '通知器',
|
||||
'indexes': [models.Index(fields=['merchant', 'event_key', 'is_enabled'], name='notifier_no_merchan_c59a7a_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('merchant', 'name'), name='unique_notifier_name_per_merchant')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
notifier/migrations/__init__.py
Normal file
0
notifier/migrations/__init__.py
Normal file
63
notifier/models.py
Normal file
63
notifier/models.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from django.db import models
|
||||
|
||||
from flower.common import ModelBase
|
||||
|
||||
|
||||
class NotifierChannelEnum(models.TextChoices):
|
||||
WECOM_WEBHOOK = "wecom_webhook", "企业微信机器人"
|
||||
|
||||
|
||||
class NotificationEventKeyEnum(models.TextChoices):
|
||||
MISSION_CREATED = "mission.created", "任务已创建"
|
||||
MISSION_REPLIED = "mission.replied", "任务有新回应"
|
||||
MISSION_COMPLETED = "mission.completed", "任务已完成"
|
||||
MISSION_REPLY_REJECTED = "mission.reply_rejected", "任务回应已撤销"
|
||||
MISSION_REOPENED = "mission.reopened", "任务已重新打开"
|
||||
MISSION_CANCELLED = "mission.cancelled", "任务已取消"
|
||||
|
||||
|
||||
class Notifier(ModelBase):
|
||||
id = models.BigAutoField(primary_key=True)
|
||||
merchant = models.ForeignKey(
|
||||
"basic_info.Merchant",
|
||||
on_delete=models.PROTECT,
|
||||
related_name="notifiers",
|
||||
verbose_name="所属商户",
|
||||
)
|
||||
name = models.CharField(max_length=100, verbose_name="通知器名称")
|
||||
event_key = models.CharField(
|
||||
max_length=100,
|
||||
choices=NotificationEventKeyEnum.choices,
|
||||
db_index=True,
|
||||
verbose_name="事件标识",
|
||||
)
|
||||
channel = models.CharField(
|
||||
max_length=50,
|
||||
choices=NotifierChannelEnum.choices,
|
||||
default=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
verbose_name="通知渠道",
|
||||
)
|
||||
template_key = models.CharField(max_length=100, verbose_name="模板标识")
|
||||
is_enabled = models.BooleanField(default=True, db_index=True, verbose_name="是否启用")
|
||||
config = models.JSONField(default=dict, blank=True, verbose_name="渠道配置")
|
||||
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
|
||||
|
||||
def get_template_name(self) -> str:
|
||||
return f"notifier/events/{self.template_key}.md"
|
||||
|
||||
def get_config_value(self, key: str, default=None):
|
||||
config = self.config or {}
|
||||
return config.get(key, default)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.event_key})"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "通知器"
|
||||
verbose_name_plural = "通知器"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=["merchant", "name"], name="unique_notifier_name_per_merchant"),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["merchant", "event_key", "is_enabled"]),
|
||||
]
|
||||
15
notifier/registry.py
Normal file
15
notifier/registry.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from notifier.backends import WeComWebhookNotifierBackend
|
||||
from notifier.models import NotifierChannelEnum
|
||||
|
||||
|
||||
BACKEND_REGISTRY = {
|
||||
NotifierChannelEnum.WECOM_WEBHOOK: WeComWebhookNotifierBackend,
|
||||
}
|
||||
|
||||
|
||||
def get_notifier_backend(channel: str):
|
||||
try:
|
||||
backend_cls = BACKEND_REGISTRY[channel]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"不支持的通知渠道: {channel}") from exc
|
||||
return backend_cls()
|
||||
99
notifier/services.py
Normal file
99
notifier/services.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import logging
|
||||
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from notifier.models import Notifier
|
||||
from notifier.registry import get_notifier_backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def render_notification_content(*, notifier: Notifier, payload: dict) -> str:
|
||||
content = render_to_string(notifier.get_template_name(), payload or {}).strip()
|
||||
if not content:
|
||||
raise ValueError("通知模板渲染结果不能为空")
|
||||
return content
|
||||
|
||||
|
||||
def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dict:
|
||||
content = render_notification_content(notifier=notifier, payload=payload)
|
||||
backend = get_notifier_backend(notifier.channel)
|
||||
backend_result = backend.notify(notifier=notifier, content=content, context=payload)
|
||||
result = {
|
||||
"notifier_id": notifier.id,
|
||||
"notifier_name": notifier.name,
|
||||
"event_key": notifier.event_key,
|
||||
"channel": notifier.channel,
|
||||
"template_key": notifier.template_key,
|
||||
"status": "sent",
|
||||
**backend_result,
|
||||
}
|
||||
logger.info("[notifier.services] notification sent: %s", result)
|
||||
return result
|
||||
|
||||
|
||||
def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[dict]:
|
||||
notifiers = list(
|
||||
Notifier.objects.filter(
|
||||
merchant_id=merchant_id,
|
||||
event_key=event_key,
|
||||
is_enabled=True,
|
||||
).order_by("id")
|
||||
)
|
||||
if not notifiers:
|
||||
logger.info(
|
||||
"[notifier.services] no enabled notifier matched: event_key=%s merchant_id=%s",
|
||||
event_key,
|
||||
merchant_id,
|
||||
)
|
||||
return []
|
||||
|
||||
results = []
|
||||
for notifier in notifiers:
|
||||
try:
|
||||
results.append(send_notification_with_notifier(notifier=notifier, payload=payload or {}))
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"[notifier.services] notification failed: notifier_id=%s event_key=%s merchant_id=%s",
|
||||
notifier.id,
|
||||
event_key,
|
||||
merchant_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"notifier_id": notifier.id,
|
||||
"notifier_name": notifier.name,
|
||||
"event_key": notifier.event_key,
|
||||
"channel": notifier.channel,
|
||||
"template_key": notifier.template_key,
|
||||
"status": "failed",
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def enqueue_notification_event(*, event_key: str, merchant_id: int, payload: dict | None = None) -> str | None:
|
||||
from notifier.tasks import dispatch_notification_event_task
|
||||
|
||||
try:
|
||||
async_result = dispatch_notification_event_task.delay(
|
||||
event_key=event_key,
|
||||
merchant_id=merchant_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"[notifier.services] queue notification task failed: event_key=%s merchant_id=%s",
|
||||
event_key,
|
||||
merchant_id,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[notifier.services] queued notification task: event_key=%s merchant_id=%s task_id=%s",
|
||||
event_key,
|
||||
merchant_id,
|
||||
async_result.id,
|
||||
)
|
||||
return async_result.id
|
||||
27
notifier/tasks.py
Normal file
27
notifier/tasks.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
from notifier.services import dispatch_notification_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def dispatch_notification_event_task(self, *, event_key: str, merchant_id: int, payload: dict | None = None) -> dict:
|
||||
results = dispatch_notification_event(
|
||||
event_key=event_key,
|
||||
merchant_id=merchant_id,
|
||||
payload=payload or {},
|
||||
)
|
||||
summary = {
|
||||
"task_id": self.request.id,
|
||||
"event_key": event_key,
|
||||
"merchant_id": merchant_id,
|
||||
"total_count": len(results),
|
||||
"sent_count": sum(1 for item in results if item.get("status") == "sent"),
|
||||
"failed_count": sum(1 for item in results if item.get("status") == "failed"),
|
||||
"results": results,
|
||||
}
|
||||
logger.info("[notifier.tasks] notification task finished: %s", summary)
|
||||
return summary
|
||||
6
notifier/templates/notifier/events/mission_cancelled.md
Normal file
6
notifier/templates/notifier/events/mission_cancelled.md
Normal file
@@ -0,0 +1,6 @@
|
||||
任务已取消
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
取消人:{{ cancelled_by_name }}
|
||||
取消时间:{{ cancelled_at }}
|
||||
任务描述:{{ description }}
|
||||
7
notifier/templates/notifier/events/mission_completed.md
Normal file
7
notifier/templates/notifier/events/mission_completed.md
Normal file
@@ -0,0 +1,7 @@
|
||||
任务已完成
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
完成人:{{ completed_by_name }}
|
||||
结束回应ID:{{ reply_id|default:"" }}
|
||||
结束回应内容:{{ reply_content|default:"" }}
|
||||
任务描述:{{ description }}
|
||||
8
notifier/templates/notifier/events/mission_created.md
Normal file
8
notifier/templates/notifier/events/mission_created.md
Normal file
@@ -0,0 +1,8 @@
|
||||
任务已创建
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
创建人:{{ created_by_name|default:creator_name }}
|
||||
分类:{{ category_name }}
|
||||
紧急:{% if is_urgent %}是{% else %}否{% endif %}
|
||||
参与者:{{ participant_names_display }}
|
||||
描述:{{ description }}
|
||||
6
notifier/templates/notifier/events/mission_reopened.md
Normal file
6
notifier/templates/notifier/events/mission_reopened.md
Normal file
@@ -0,0 +1,6 @@
|
||||
任务已重新打开
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
操作人:{{ reopened_by_name }}
|
||||
被撤销的结束回应ID:{{ rejected_reply_ids_display }}
|
||||
任务描述:{{ description }}
|
||||
8
notifier/templates/notifier/events/mission_replied.md
Normal file
8
notifier/templates/notifier/events/mission_replied.md
Normal file
@@ -0,0 +1,8 @@
|
||||
任务有新回应
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
回应ID:{{ reply_id }}
|
||||
回应者:{{ responder_name }}
|
||||
是否结束任务:{% if ends_task %}是{% else %}否{% endif %}
|
||||
回应内容:{{ reply_content }}
|
||||
任务描述:{{ description }}
|
||||
@@ -0,0 +1,7 @@
|
||||
任务回应已撤销
|
||||
|
||||
任务ID:{{ mission_id }}
|
||||
回应ID:{{ reply_id }}
|
||||
撤销人:{{ rejected_by_name }}
|
||||
撤销原因:{{ reason }}
|
||||
原回应内容:{{ reply_content }}
|
||||
124
notifier/tests.py
Normal file
124
notifier/tests.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from basic_info.models import Merchant, MerchantTypeEnum
|
||||
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum
|
||||
from notifier.services import (
|
||||
dispatch_notification_event,
|
||||
enqueue_notification_event,
|
||||
render_notification_content,
|
||||
send_notification_with_notifier,
|
||||
)
|
||||
|
||||
|
||||
class NotifierServiceTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.merchant = Merchant.objects.create(name="通知商户", type=MerchantTypeEnum.STORE)
|
||||
self.notifier = Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务创建通知",
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_created",
|
||||
config={"key": "abc123", "msgtype": "markdown"},
|
||||
)
|
||||
|
||||
def test_render_notification_content(self):
|
||||
content = render_notification_content(
|
||||
notifier=self.notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"created_by_name": "张三",
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"is_urgent": False,
|
||||
"participant_names_display": "李四、王五",
|
||||
"description": "检查打印质量",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIn("任务已创建", content)
|
||||
self.assertIn("任务ID:12", content)
|
||||
self.assertIn("创建人:张三", content)
|
||||
|
||||
@patch("notifier.backends.send_wecom_webhook_message")
|
||||
def test_send_notification_with_notifier_uses_wecom_backend(self, mock_send):
|
||||
mock_send.return_value.ok = True
|
||||
mock_send.return_value.errcode = 0
|
||||
mock_send.return_value.errmsg = "ok"
|
||||
|
||||
result = send_notification_with_notifier(
|
||||
notifier=self.notifier,
|
||||
payload={
|
||||
"mission_id": 12,
|
||||
"created_by_name": "张三",
|
||||
"creator_name": "张三",
|
||||
"category_name": "通用",
|
||||
"is_urgent": True,
|
||||
"participant_names_display": "无",
|
||||
"description": "检查打印质量",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "sent")
|
||||
self.assertEqual(result["channel"], NotifierChannelEnum.WECOM_WEBHOOK)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
@patch("notifier.services.send_notification_with_notifier")
|
||||
def test_dispatch_notification_event_filters_by_event_and_enabled(self, mock_send):
|
||||
mock_send.side_effect = lambda *, notifier, payload: {
|
||||
"notifier_id": notifier.id,
|
||||
"status": "sent",
|
||||
}
|
||||
Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="任务回应通知",
|
||||
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_replied",
|
||||
config={"key": "def456"},
|
||||
)
|
||||
Notifier.objects.create(
|
||||
merchant=self.merchant,
|
||||
name="停用通知器",
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
channel=NotifierChannelEnum.WECOM_WEBHOOK,
|
||||
template_key="mission_created",
|
||||
is_enabled=False,
|
||||
config={"key": "ghi789"},
|
||||
)
|
||||
|
||||
results = dispatch_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
merchant_id=self.merchant.id,
|
||||
payload={"mission_id": 99},
|
||||
)
|
||||
|
||||
self.assertEqual(results, [{"notifier_id": self.notifier.id, "status": "sent"}])
|
||||
self.assertEqual(mock_send.call_count, 1)
|
||||
self.assertEqual(mock_send.call_args.kwargs["notifier"].id, self.notifier.id)
|
||||
|
||||
@patch("notifier.tasks.dispatch_notification_event_task.delay")
|
||||
def test_enqueue_notification_event_returns_task_id(self, mock_delay):
|
||||
mock_delay.return_value.id = "task-123"
|
||||
|
||||
task_id = enqueue_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
merchant_id=self.merchant.id,
|
||||
payload={"mission_id": 1},
|
||||
)
|
||||
|
||||
self.assertEqual(task_id, "task-123")
|
||||
mock_delay.assert_called_once()
|
||||
|
||||
@patch("notifier.tasks.dispatch_notification_event_task.delay", side_effect=RuntimeError("broker down"))
|
||||
def test_enqueue_notification_event_fails_open(self, mock_delay):
|
||||
task_id = enqueue_notification_event(
|
||||
event_key=NotificationEventKeyEnum.MISSION_CREATED,
|
||||
merchant_id=self.merchant.id,
|
||||
payload={"mission_id": 1},
|
||||
)
|
||||
|
||||
self.assertIsNone(task_id)
|
||||
mock_delay.assert_called_once()
|
||||
3
notifier/views.py
Normal file
3
notifier/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
Reference in New Issue
Block a user