1
0
forked from erp-dev/erp

feat: notifier beta

This commit is contained in:
2026-04-13 12:30:33 +08:00
parent 646dbe7f18
commit 9512132bb9
50 changed files with 17830 additions and 2 deletions

0
notifier/__init__.py Normal file
View File

20
notifier/admin.py Normal file
View 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
View 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
View 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

View 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')],
},
),
]

View File

63
notifier/models.py Normal file
View 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
View 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
View 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
View 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

View File

@@ -0,0 +1,6 @@
任务已取消
任务ID{{ mission_id }}
取消人:{{ cancelled_by_name }}
取消时间:{{ cancelled_at }}
任务描述:{{ description }}

View File

@@ -0,0 +1,7 @@
任务已完成
任务ID{{ mission_id }}
完成人:{{ completed_by_name }}
结束回应ID{{ reply_id|default:"" }}
结束回应内容:{{ reply_content|default:"" }}
任务描述:{{ description }}

View 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 }}

View File

@@ -0,0 +1,6 @@
任务已重新打开
任务ID{{ mission_id }}
操作人:{{ reopened_by_name }}
被撤销的结束回应ID{{ rejected_reply_ids_display }}
任务描述:{{ description }}

View File

@@ -0,0 +1,8 @@
任务有新回应
任务ID{{ mission_id }}
回应ID{{ reply_id }}
回应者:{{ responder_name }}
是否结束任务:{% if ends_task %}是{% else %}否{% endif %}
回应内容:{{ reply_content }}
任务描述:{{ description }}

View File

@@ -0,0 +1,7 @@
任务回应已撤销
任务ID{{ mission_id }}
回应ID{{ reply_id }}
撤销人:{{ rejected_by_name }}
撤销原因:{{ reason }}
原回应内容:{{ reply_content }}

124
notifier/tests.py Normal file
View 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("任务ID12", 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
View File

@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.