1
0
forked from erp-dev/erp

feat: mission category

This commit is contained in:
2026-04-14 00:03:22 +08:00
parent 9512132bb9
commit 0167478a25
18 changed files with 852 additions and 354 deletions

View File

@@ -1,20 +1,52 @@
from django.contrib import admin
from notifier.models import Notifier
from notifier.models import Notifier, NotifierRoute
class NotifierRouteInline(admin.TabularInline):
model = NotifierRoute
extra = 0
fields = ["event_key", "mission_category", "is_enabled", "description"]
@admin.register(Notifier)
class NotifierAdmin(admin.ModelAdmin):
inlines = [NotifierRouteInline]
list_display = [
"id",
"merchant",
"name",
"event_key",
"channel",
"template_key",
"is_enabled",
"created_at",
]
list_filter = ["merchant", "event_key", "channel", "is_enabled", "created_at"]
list_filter = ["merchant", "channel", "is_enabled", "created_at"]
search_fields = ["name", "template_key", "description"]
readonly_fields = ["created_at", "updated_at"]
def save_formset(self, request, form, formset, change):
instances = formset.save(commit=False)
for obj in formset.deleted_objects:
obj.delete()
for instance in instances:
if isinstance(instance, NotifierRoute):
instance.merchant = form.instance.merchant
instance.save()
formset.save_m2m()
@admin.register(NotifierRoute)
class NotifierRouteAdmin(admin.ModelAdmin):
list_display = [
"id",
"merchant",
"notifier",
"event_key",
"mission_category",
"is_enabled",
"created_at",
]
list_filter = ["merchant", "event_key", "mission_category", "is_enabled", "created_at"]
search_fields = ["notifier__name", "description"]
readonly_fields = ["created_at", "updated_at"]

View File

@@ -0,0 +1,65 @@
import django.db.models.deletion
from django.db import migrations, models
from django.db.models import Q
def forwards(apps, schema_editor):
Notifier = apps.get_model("notifier", "Notifier")
NotifierRoute = apps.get_model("notifier", "NotifierRoute")
db_alias = schema_editor.connection.alias
for notifier in Notifier.objects.using(db_alias).all().only("id", "merchant_id", "event_key").iterator():
NotifierRoute.objects.using(db_alias).get_or_create(
notifier_id=notifier.id,
event_key=notifier.event_key,
mission_category_id=None,
defaults={
"merchant_id": notifier.merchant_id,
"is_enabled": True,
"description": "由旧版 notifier.event_key 自动迁移生成",
},
)
class Migration(migrations.Migration):
dependencies = [
("mission", "0004_missioncategory_refactor"),
("notifier", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="NotifierRoute",
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)),
("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="事件标识")),
("is_enabled", models.BooleanField(db_index=True, default=True, verbose_name="是否启用")),
("description", models.TextField(blank=True, null=True, verbose_name="备注描述")),
("merchant", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="basic_info.merchant", verbose_name="所属商户")),
("mission_category", models.ForeignKey(blank=True, help_text="为空时表示该事件的通配路由", null=True, on_delete=django.db.models.deletion.PROTECT, related_name="notifier_routes", to="mission.missioncategory", verbose_name="任务分类路由")),
("notifier", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="routes", to="notifier.notifier", verbose_name="通知器")),
],
options={
"verbose_name": "通知路由",
"verbose_name_plural": "通知路由",
"indexes": [models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"), models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx")],
"constraints": [models.UniqueConstraint(fields=("notifier", "event_key", "mission_category"), name="unique_notifier_route_per_scope"), models.UniqueConstraint(condition=Q(mission_category__isnull=True), fields=("notifier", "event_key"), name="unique_notifier_route_global_scope")],
},
),
migrations.RunPython(forwards, migrations.RunPython.noop),
migrations.RemoveIndex(
model_name="notifier",
name="notifier_no_merchan_c59a7a_idx",
),
migrations.RemoveField(
model_name="notifier",
name="event_key",
),
migrations.AddIndex(
model_name="notifier",
index=models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
),
]

View File

@@ -1,4 +1,6 @@
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from flower.common import ModelBase
@@ -25,12 +27,6 @@ class Notifier(ModelBase):
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,
@@ -50,7 +46,7 @@ class Notifier(ModelBase):
return config.get(key, default)
def __str__(self):
return f"{self.name} ({self.event_key})"
return self.name
class Meta:
verbose_name = "通知器"
@@ -59,5 +55,70 @@ class Notifier(ModelBase):
models.UniqueConstraint(fields=["merchant", "name"], name="unique_notifier_name_per_merchant"),
]
indexes = [
models.Index(fields=["merchant", "event_key", "is_enabled"]),
models.Index(fields=["merchant", "channel", "is_enabled"], name="notifier_no_merchan_532b51_idx"),
]
class NotifierRoute(ModelBase):
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="notifier_routes",
verbose_name="所属商户",
)
notifier = models.ForeignKey(
Notifier,
on_delete=models.CASCADE,
related_name="routes",
verbose_name="通知器",
)
event_key = models.CharField(
max_length=100,
choices=NotificationEventKeyEnum.choices,
db_index=True,
verbose_name="事件标识",
)
mission_category = models.ForeignKey(
"mission.MissionCategory",
on_delete=models.PROTECT,
related_name="notifier_routes",
null=True,
blank=True,
verbose_name="任务分类路由",
help_text="为空时表示该事件的通配路由",
)
is_enabled = models.BooleanField(default=True, db_index=True, verbose_name="是否启用")
description = models.TextField(blank=True, null=True, verbose_name="备注描述")
def clean(self):
errors = {}
if self.notifier_id and self.merchant_id and self.notifier.merchant_id != self.merchant_id:
errors["merchant"] = "路由所属商户必须与通知器所属商户一致"
if self.mission_category_id and self.merchant_id and self.mission_category.merchant_id != self.merchant_id:
errors["mission_category"] = "任务分类必须属于当前路由商户"
if errors:
raise ValidationError(errors)
def __str__(self):
category_name = self.mission_category.name if self.mission_category_id else "全部分类"
return f"{self.notifier.name} -> {self.event_key} [{category_name}]"
class Meta:
verbose_name = "通知路由"
verbose_name_plural = "通知路由"
constraints = [
models.UniqueConstraint(
fields=["notifier", "event_key", "mission_category"],
name="unique_notifier_route_per_scope",
),
models.UniqueConstraint(
fields=["notifier", "event_key"],
condition=Q(mission_category__isnull=True),
name="unique_notifier_route_global_scope",
),
]
indexes = [
models.Index(fields=["merchant", "event_key", "is_enabled"], name="notifier_no_merchan_58d463_idx"),
models.Index(fields=["merchant", "mission_category", "is_enabled"], name="notifier_no_merchan_14b7f6_idx"),
]

View File

@@ -1,8 +1,9 @@
import logging
from django.db.models import Case, IntegerField, Q, Value, When
from django.template.loader import render_to_string
from notifier.models import Notifier
from notifier.models import Notifier, NotifierRoute
from notifier.registry import get_notifier_backend
logger = logging.getLogger(__name__)
@@ -22,7 +23,6 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
result = {
"notifier_id": notifier.id,
"notifier_name": notifier.name,
"event_key": notifier.event_key,
"channel": notifier.channel,
"template_key": notifier.template_key,
"status": "sent",
@@ -32,29 +32,78 @@ def send_notification_with_notifier(*, notifier: Notifier, payload: dict) -> dic
return result
def _match_notifier_routes(*, event_key: str, merchant_id: int, payload: dict | None = None) -> list[NotifierRoute]:
payload = payload or {}
category_id = payload.get("category_id")
queryset = NotifierRoute.objects.filter(
merchant_id=merchant_id,
event_key=event_key,
is_enabled=True,
notifier__is_enabled=True,
).select_related("notifier", "mission_category")
if category_id is not None:
queryset = queryset.filter(Q(mission_category_id=category_id) | Q(mission_category__isnull=True)).annotate(
route_priority=Case(
When(mission_category_id=category_id, then=Value(0)),
default=Value(1),
output_field=IntegerField(),
)
)
else:
queryset = queryset.filter(mission_category__isnull=True).annotate(
route_priority=Value(0, output_field=IntegerField())
)
queryset = queryset.order_by("notifier_id", "route_priority", "id")
matched_by_notifier_id = {}
for route in queryset:
matched_by_notifier_id.setdefault(route.notifier_id, route)
return list(matched_by_notifier_id.values())
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")
payload = payload or {}
routes = _match_notifier_routes(
event_key=event_key,
merchant_id=merchant_id,
payload=payload,
)
if not notifiers:
if not routes:
logger.info(
"[notifier.services] no enabled notifier matched: event_key=%s merchant_id=%s",
"[notifier.services] no enabled notifier route matched: event_key=%s merchant_id=%s category_id=%s",
event_key,
merchant_id,
payload.get("category_id"),
)
return []
results = []
for notifier in notifiers:
for route in routes:
notifier = route.notifier
try:
results.append(send_notification_with_notifier(notifier=notifier, payload=payload or {}))
item = send_notification_with_notifier(notifier=notifier, payload=payload)
item.update(
{
"event_key": event_key,
"route_id": route.id,
"route_event_key": route.event_key,
"route_mission_category_id": route.mission_category_id,
}
)
results.append(item)
logger.info(
"[notifier.services] notification routed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s route_category_id=%s",
route.id,
notifier.id,
event_key,
merchant_id,
route.mission_category_id,
)
except Exception as exc:
logger.exception(
"[notifier.services] notification failed: notifier_id=%s event_key=%s merchant_id=%s",
"[notifier.services] notification failed: route_id=%s notifier_id=%s event_key=%s merchant_id=%s",
route.id,
notifier.id,
event_key,
merchant_id,
@@ -63,9 +112,12 @@ def dispatch_notification_event(*, event_key: str, merchant_id: int, payload: di
{
"notifier_id": notifier.id,
"notifier_name": notifier.name,
"event_key": notifier.event_key,
"event_key": event_key,
"channel": notifier.channel,
"template_key": notifier.template_key,
"route_id": route.id,
"route_event_key": route.event_key,
"route_mission_category_id": route.mission_category_id,
"status": "failed",
"error": str(exc),
}

View File

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

View File

@@ -1,6 +1,7 @@
任务已完成
任务ID{{ mission_id }}
分类:{{ category_name }}
完成人:{{ completed_by_name }}
结束回应ID{{ reply_id|default:"" }}
结束回应内容:{{ reply_content|default:"" }}

View File

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

View File

@@ -1,6 +1,7 @@
任务有新回应
任务ID{{ mission_id }}
分类:{{ category_name }}
回应ID{{ reply_id }}
回应者:{{ responder_name }}
是否结束任务:{% if ends_task %}是{% else %}否{% endif %}

View File

@@ -1,6 +1,7 @@
任务回应已撤销
任务ID{{ mission_id }}
分类:{{ category_name }}
回应ID{{ reply_id }}
撤销人:{{ rejected_by_name }}
撤销原因:{{ reason }}

View File

@@ -3,7 +3,8 @@ 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 mission.models import MissionCategory
from notifier.models import NotificationEventKeyEnum, Notifier, NotifierChannelEnum, NotifierRoute
from notifier.services import (
dispatch_notification_event,
enqueue_notification_event,
@@ -15,14 +16,20 @@ from notifier.services import (
class NotifierServiceTestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name="通知商户", type=MerchantTypeEnum.STORE)
self.general_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
self.after_sale_category = MissionCategory.objects.create(merchant=self.merchant, name="售后")
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"},
)
self.global_route = NotifierRoute.objects.create(
merchant=self.merchant,
notifier=self.notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
)
def test_render_notification_content(self):
content = render_notification_content(
@@ -66,28 +73,36 @@ class NotifierServiceTestCase(TestCase):
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):
def test_dispatch_notification_event_filters_by_route_event_and_enabled(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
Notifier.objects.create(
replied_notifier = 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(
NotifierRoute.objects.create(
merchant=self.merchant,
notifier=replied_notifier,
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
)
disabled_notifier = 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"},
)
NotifierRoute.objects.create(
merchant=self.merchant,
notifier=disabled_notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
)
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
@@ -95,10 +110,62 @@ class NotifierServiceTestCase(TestCase):
payload={"mission_id": 99},
)
self.assertEqual(results, [{"notifier_id": self.notifier.id, "status": "sent"}])
self.assertEqual(
results,
[
{
"notifier_id": self.notifier.id,
"status": "sent",
"event_key": NotificationEventKeyEnum.MISSION_CREATED,
"route_id": self.global_route.id,
"route_event_key": NotificationEventKeyEnum.MISSION_CREATED,
"route_mission_category_id": None,
}
],
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(mock_send.call_args.kwargs["notifier"].id, self.notifier.id)
@patch("notifier.services.send_notification_with_notifier")
def test_dispatch_notification_event_prefers_category_specific_route(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
specific_route = NotifierRoute.objects.create(
merchant=self.merchant,
notifier=self.notifier,
event_key=NotificationEventKeyEnum.MISSION_CREATED,
mission_category=self.after_sale_category,
)
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=self.merchant.id,
payload={"mission_id": 99, "category_id": self.after_sale_category.id},
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(results[0]["route_id"], specific_route.id)
self.assertEqual(results[0]["route_mission_category_id"], self.after_sale_category.id)
@patch("notifier.services.send_notification_with_notifier")
def test_dispatch_notification_event_falls_back_to_global_route(self, mock_send):
mock_send.side_effect = lambda *, notifier, payload: {
"notifier_id": notifier.id,
"status": "sent",
}
results = dispatch_notification_event(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=self.merchant.id,
payload={"mission_id": 99, "category_id": self.general_category.id},
)
self.assertEqual(mock_send.call_count, 1)
self.assertEqual(results[0]["route_id"], self.global_route.id)
self.assertIsNone(results[0]["route_mission_category_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"