From 0167478a252d2de325c388b43e346bf3cc111c88 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 14 Apr 2026 00:03:22 +0800 Subject: [PATCH] feat: mission category --- api_v1/views/shipment/test_api.py | 26 ++ api_v1/views/shipment/views.py | 8 + basic_info/admin.py | 30 ++ .../0026_transportvehicle_and_capacity.py | 45 +++ basic_info/models.py | 36 ++ basic_info/tests.py | 41 +- docs/notifier_admin_guide.md | 369 ++++++++++-------- docs/notifier_module_design.md | 303 +++++++------- notifier/admin.py | 38 +- .../migrations/0002_notifierroute_refactor.py | 65 +++ notifier/models.py | 77 +++- notifier/services.py | 80 +++- .../notifier/events/mission_cancelled.md | 1 + .../notifier/events/mission_completed.md | 1 + .../notifier/events/mission_reopened.md | 1 + .../notifier/events/mission_replied.md | 1 + .../notifier/events/mission_reply_rejected.md | 1 + notifier/tests.py | 83 +++- 18 files changed, 852 insertions(+), 354 deletions(-) create mode 100644 basic_info/migrations/0026_transportvehicle_and_capacity.py create mode 100644 notifier/migrations/0002_notifierroute_refactor.py diff --git a/api_v1/views/shipment/test_api.py b/api_v1/views/shipment/test_api.py index e44e9cc..59004be 100644 --- a/api_v1/views/shipment/test_api.py +++ b/api_v1/views/shipment/test_api.py @@ -1681,6 +1681,32 @@ class ShipmentQueryAPITestCase(TestCase): self.assertIn("external_finished_products", item) self.assertIsInstance(item["external_finished_products"], list) + def test_list_shipments_supports_only_address_null_filter(self): + self.shipment1.address = "" + self.shipment1.save(update_fields=["address", "updated_at"]) + self.shipment2.address = "" + self.shipment2.save(update_fields=["address", "updated_at"]) + + shipment_with_address = shipment_models.Shipment.objects.create( + merchant=self.merchant1, + customer=self.customer1, + shipment_date="2026-01-16", + created_by=self.user1, + address="绍兴市测试路 8 号", + ) + + resp_true = self.client.get("/api/v1/shipment/shipments/?only_address_null=true") + self.assertEqual(resp_true.status_code, status.HTTP_200_OK) + true_ids = [it["id"] for it in resp_true.json()["results"]] + self.assertIn(self.shipment1.id, true_ids) + self.assertNotIn(shipment_with_address.id, true_ids) + + resp_false = self.client.get("/api/v1/shipment/shipments/?only_address_null=false") + self.assertEqual(resp_false.status_code, status.HTTP_200_OK) + false_ids = [it["id"] for it in resp_false.json()["results"]] + self.assertIn(shipment_with_address.id, false_ids) + self.assertNotIn(self.shipment1.id, false_ids) + def test_retrieve_shipment_success(self): resp = self.client.get(f"/api/v1/shipment/shipments/{self.shipment1.id}/") self.assertEqual(resp.status_code, status.HTTP_200_OK) diff --git a/api_v1/views/shipment/views.py b/api_v1/views/shipment/views.py index 375bd87..0b85e9a 100644 --- a/api_v1/views/shipment/views.py +++ b/api_v1/views/shipment/views.py @@ -164,6 +164,14 @@ class ShipmentListCreateView(ListModelMixin, GenericAPIView): # 通过 <= 过滤日期 qs = qs.filter(shipment_date__lte=date_to) + only_address_null = self.request.query_params.get("only_address_null") + if only_address_null is not None: + normalized = only_address_null.strip().lower() + if normalized in {"1", "true", "yes"}: + qs = qs.filter(address="") + elif normalized in {"0", "false", "no"}: + qs = qs.exclude(address="") + return qs.order_by("-created_at", "-id") def get(self, request): diff --git a/basic_info/admin.py b/basic_info/admin.py index b516643..f69cc75 100644 --- a/basic_info/admin.py +++ b/basic_info/admin.py @@ -194,3 +194,33 @@ class VehicleTransportRecordAdmin(AdminBase): list_display = ('driver_name', 'contact_number', 'delivery_date') search_fields = ('driver_name', 'contact_number') list_filter = ('vehicle_type',) + + +class TransportVehicleMaterialCapacityInline(admin.TabularInline): + model = models.TransportVehicleMaterialCapacity + extra = 0 + fields = ('material_name', 'capacity') + + +@admin.register(models.TransportVehicle) +class TransportVehicleAdmin(AdminBase): + inlines = [TransportVehicleMaterialCapacityInline] + list_display = ('name', 'license_plate') + search_fields = ('name', 'license_plate') + + 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, models.TransportVehicleMaterialCapacity): + instance.merchant = form.instance.merchant + instance.save() + formset.save_m2m() + + +@admin.register(models.TransportVehicleMaterialCapacity) +class TransportVehicleMaterialCapacityAdmin(AdminBase): + list_display = ('transport_vehicle', 'material_name', 'capacity') + search_fields = ('transport_vehicle__name', 'transport_vehicle__license_plate', 'material_name') + list_filter = ('transport_vehicle',) diff --git a/basic_info/migrations/0026_transportvehicle_and_capacity.py b/basic_info/migrations/0026_transportvehicle_and_capacity.py new file mode 100644 index 0000000..85ea5a0 --- /dev/null +++ b/basic_info/migrations/0026_transportvehicle_and_capacity.py @@ -0,0 +1,45 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('basic_info', '0025_customer_uniq_customer_merchant_name'), + ] + + operations = [ + migrations.CreateModel( + name='TransportVehicle', + 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='车辆名称')), + ('license_plate', models.CharField(max_length=20, verbose_name='车牌号码')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transport_vehicles', to='basic_info.merchant', verbose_name='所属商户')), + ], + options={ + 'verbose_name': '运输车辆', + 'verbose_name_plural': '运输车辆', + 'unique_together': {('merchant', 'license_plate')}, + }, + ), + migrations.CreateModel( + name='TransportVehicleMaterialCapacity', + 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)), + ('material_name', models.CharField(max_length=100, verbose_name='物料名')), + ('capacity', models.PositiveIntegerField(verbose_name='容量(条)')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='transport_vehicle_material_capacities', to='basic_info.merchant', verbose_name='所属商户')), + ('transport_vehicle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='material_capacities', to='basic_info.transportvehicle', verbose_name='运输车辆')), + ], + options={ + 'verbose_name': '运输车辆物料容量', + 'verbose_name_plural': '运输车辆物料容量', + 'unique_together': {('transport_vehicle', 'material_name')}, + }, + ), + ] diff --git a/basic_info/models.py b/basic_info/models.py index 853a75f..122c440 100644 --- a/basic_info/models.py +++ b/basic_info/models.py @@ -574,6 +574,42 @@ class VehicleTransportRecord(ModelBase): verbose_name_plural = '司机车次' +class TransportVehicle(ModelBase): + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='transport_vehicles', verbose_name='所属商户') + name = models.CharField(max_length=100, verbose_name='车辆名称') + license_plate = models.CharField(max_length=20, verbose_name='车牌号码') + + def __str__(self): + return f'{self.name} ({self.license_plate})' + + class Meta: + verbose_name = '运输车辆' + verbose_name_plural = '运输车辆' + unique_together = ('merchant', 'license_plate') + + +class TransportVehicleMaterialCapacity(ModelBase): + id = models.BigAutoField(primary_key=True) + merchant = models.ForeignKey('Merchant', on_delete=models.PROTECT, related_name='transport_vehicle_material_capacities', verbose_name='所属商户') + transport_vehicle = models.ForeignKey( + TransportVehicle, + on_delete=models.CASCADE, + related_name='material_capacities', + verbose_name='运输车辆', + ) + material_name = models.CharField(max_length=100, verbose_name='物料名') + capacity = models.PositiveIntegerField(verbose_name='容量(条)') + + def __str__(self): + return f'{self.transport_vehicle.name}-{self.material_name}:{self.capacity}' + + class Meta: + verbose_name = '运输车辆物料容量' + verbose_name_plural = '运输车辆物料容量' + unique_together = ('transport_vehicle', 'material_name') + + class MerchantSettingTypeEnum(models.TextChoices): """商户设置类型枚举""" STR = 'str', '字符串' diff --git a/basic_info/tests.py b/basic_info/tests.py index a1af04b..9692c5e 100644 --- a/basic_info/tests.py +++ b/basic_info/tests.py @@ -7,7 +7,7 @@ from django.core.exceptions import ValidationError from .models import ( Merchant, MerchantTypeEnum, WareHouse, WarehouseTypeEnum, WareHouseModeEnum, EmployeeType, Employee, EmployeeStatusEnum, - FrontendPage, FrontendPageTypeEnum + FrontendPage, FrontendPageTypeEnum, TransportVehicle, TransportVehicleMaterialCapacity ) @@ -171,6 +171,45 @@ class EmployeeTypeTestCase(TestCase): self.assertEqual(emp_type.employees.count(), 2) +class TransportVehicleTestCase(TestCase): + def setUp(self): + self.merchant = Merchant.objects.create( + name='运输商户', + type=MerchantTypeEnum.STORE + ) + + def test_create_transport_vehicle_with_material_capacity(self): + vehicle = TransportVehicle.objects.create( + merchant=self.merchant, + name='9.6米厢车', + license_plate='浙A12345' + ) + capacity = TransportVehicleMaterialCapacity.objects.create( + merchant=self.merchant, + transport_vehicle=vehicle, + material_name='棉布', + capacity=120 + ) + + self.assertEqual(vehicle.license_plate, '浙A12345') + self.assertEqual(str(vehicle), '9.6米厢车 (浙A12345)') + self.assertEqual(capacity.capacity, 120) + self.assertEqual(vehicle.material_capacities.count(), 1) + + def test_transport_vehicle_license_plate_unique_per_merchant(self): + TransportVehicle.objects.create( + merchant=self.merchant, + name='一号车', + license_plate='浙A99999' + ) + with self.assertRaises(Exception): + TransportVehicle.objects.create( + merchant=self.merchant, + name='二号车', + license_plate='浙A99999' + ) + + class FrontendPageTestCase(TestCase): """测试前端页面模型""" diff --git a/docs/notifier_admin_guide.md b/docs/notifier_admin_guide.md index e97e485..7d89ef2 100644 --- a/docs/notifier_admin_guide.md +++ b/docs/notifier_admin_guide.md @@ -1,84 +1,83 @@ # Notifier 管理人员配置说明 -本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier`,让系统在指定业务事件发生时自动发送通知。 +本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier` 与 `NotifierRoute`,让系统在指定业务事件发生时自动发送通知,并支持按任务分类分发。 -## 1. Notifier 是什么 +## 1. 两层结构 -`Notifier` 可以理解为一条“通知规则”。 +当前通知配置分成两层: -当某个业务事件发生时,系统会根据以下条件查找可用的通知器: +- `Notifier`:通知器本体,负责“发到哪里、用什么模板、走什么渠道” +- `NotifierRoute`:通知路由,负责“什么事件、什么任务分类,应该命中哪个 Notifier” -- 商户一致 -- `event_key` 一致 -- `is_enabled=True` +可以这样理解: -找到后,系统会自动: +- `Notifier` 是“发信工具” +- `NotifierRoute` 是“分发规则” -1. 使用对应模板渲染消息内容 -2. 按配置的渠道发送 -3. 记录发送日志 - -当前已支持的渠道: - -- 企业微信机器人 `wecom_webhook` - -## 2. 当前已支持的事件 +## 2. 当前已支持的 mission 事件 目前 `mission` 模块已接入以下事件: -- `mission.created`:任务创建 -- `mission.replied`:任务有新回应 -- `mission.completed`:任务完成 -- `mission.reply_rejected`:任务回应被撤销 -- `mission.reopened`:任务被重新打开 -- `mission.cancelled`:任务被取消 +- `mission.created` +- `mission.replied` +- `mission.completed` +- `mission.reply_rejected` +- `mission.reopened` +- `mission.cancelled` -如果要让某个事件发送通知,只需要在后台新增对应 `event_key` 的 `Notifier`。 +所有这些事件都支持通过 `NotifierRoute` 进行分类路由。 -## 3. 在哪里配置 +## 3. 当前支持的分类路由能力 -进入 Django Admin 后,找到: +路由匹配规则如下: + +1. 先按 `merchant + event_key + is_enabled=True` 匹配启用中的路由 +2. 如果当前任务带有分类: + - 优先匹配该分类的专用路由 + - 同时允许匹配“任务分类为空”的通配路由 +3. 如果同一个 `Notifier` 同时命中了专用路由和通配路由,只发送一次,优先使用专用路由 + +这意味着你可以实现: + +- 某个分类发到专门群 +- 未单独配置的分类发到通用群 +- 同一事件同时发多个群 + +## 4. Admin 中的两个入口 + +进入 Django Admin 后,主要会看到两个对象: - `通知器` +- `通知路由` -然后点击“新增”即可。 +推荐的管理方式: -## 4. 字段说明 +1. 先创建 `Notifier` +2. 再创建或维护它的 `NotifierRoute` -创建 `Notifier` 时,需要填写以下字段。 +在 `Notifier` 详情页中,也可以直接通过 inline 管理该通知器下的路由。 -### 4.1 merchant +## 5. Notifier 字段说明 + +### 5.1 merchant 所属商户。 -通知器只会匹配当前商户下发生的事件。 -不同商户如果都需要通知,需要分别创建各自的 `Notifier`。 +通知器只会服务于该商户下的路由和事件。 -### 4.2 name +### 5.2 name 通知器名称,仅用于后台识别和管理。 建议命名方式: -- `任务创建通知-生产群` -- `任务完成通知-老板群` -- `任务取消通知-客服群` +- `任务通知-生产群` +- `任务通知-售后群` +- `任务通知-管理群` 同一商户下名称不能重复。 -### 4.3 event_key - -要监听的业务事件标识。 - -这是最核心的绑定字段。 -它决定这条 `Notifier` 绑定到哪个业务信号。 - -例如: - -- `mission.completed` 表示“任务完成时发送” -- `mission.cancelled` 表示“任务取消时发送” - -### 4.4 channel +### 5.3 channel 通知渠道。 @@ -86,12 +85,12 @@ - `wecom_webhook` -### 4.5 template_key +### 5.4 template_key -消息模板标识。 +模板标识。 系统会根据这个字段去固定目录查找模板文件。 -当前模板目录为: +当前模板目录: `notifier/templates/notifier/events/` @@ -100,18 +99,18 @@ - `template_key = mission_completed` - 对应模板文件:`notifier/templates/notifier/events/mission_completed.md` -### 4.6 is_enabled +### 5.5 is_enabled -是否启用。 +是否启用通知器本体。 -- 勾选:该 `Notifier` 生效 -- 不勾选:该 `Notifier` 不会参与匹配和发送 +- 勾选:该通知器可被路由命中 +- 不勾选:即使路由存在,也不会发送 -### 4.7 config +### 5.6 config -渠道配置,使用 JSON 格式填写。 +渠道配置,JSON 格式。 -当前企业微信机器人建议配置如下: +当前企业微信机器人建议配置: ```json { @@ -121,105 +120,134 @@ } ``` -字段说明: - -- `key`:企业微信机器人 webhook key -- `msgtype`:消息类型,建议用 `markdown` -- `timeout_seconds`:请求超时时间,单位秒 - -### 4.8 description +### 5.7 description 备注说明,非必填。 -建议写清楚这条通知器的用途,例如: +## 6. NotifierRoute 字段说明 -- `用于生产部任务完成通知` -- `用于客服查看任务取消` +### 6.1 merchant -## 5. 配置步骤 +所属商户。 -以“任务完成时发送企业微信通知”为例: +必须与关联的 `Notifier` 属于同一商户。 -1. 进入 Admin 的 `通知器` -2. 点击“新增” -3. 选择 `merchant` -4. 填写 `name` -5. 选择 `event_key = mission.completed` -6. 选择 `channel = wecom_webhook` -7. 填写 `template_key = mission_completed` -8. 在 `config` 中填写 webhook 参数 -9. 勾选 `is_enabled` -10. 保存 +### 6.2 notifier -保存后,只要该商户下发生“任务完成”事件,系统就会自动尝试发送通知。 +要使用的通知器。 -## 6. 推荐配置示例 +### 6.3 event_key -### 6.1 示例一:任务创建通知 +要监听的业务事件。 -适合发到内部任务协作群。 +例如: -字段建议: +- `mission.created` +- `mission.completed` -- `name`: `任务创建通知-协作群` -- `event_key`: `mission.created` -- `channel`: `wecom_webhook` -- `template_key`: `mission_created` -- `is_enabled`: 勾选 +### 6.4 mission_category -`config` 示例: +任务分类路由条件。 -```json -{ - "key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", - "msgtype": "markdown", - "timeout_seconds": 10 -} -``` +- 为空:表示该事件的通配路由,适用于所有未被更具体路由覆盖的任务分类 +- 不为空:表示只处理该任务分类下的任务事件 -### 6.2 示例二:任务完成通知 +### 6.5 is_enabled -适合发到管理群或老板群。 +是否启用该路由。 -字段建议: +- 勾选:路由参与匹配 +- 不勾选:路由不会命中 + +### 6.6 description + +备注说明,建议写清楚用途,例如: + +- `任务创建-生产分类专用路由` +- `任务完成-所有分类默认路由` + +## 7. 推荐配置步骤 + +以“任务创建时,生产分类发生产群,其他分类发管理群”为例: + +### 第一步:创建两个 Notifier + +1. 创建 `Notifier A` + - `name = 任务通知-生产群` + - `channel = wecom_webhook` + - `template_key = mission_created` + - `config` 填生产群机器人 key + - `is_enabled = True` + +2. 创建 `Notifier B` + - `name = 任务通知-管理群` + - `channel = wecom_webhook` + - `template_key = mission_created` + - `config` 填管理群机器人 key + - `is_enabled = True` + +### 第二步:创建路由 + +1. 创建 `NotifierRoute A1` + - `notifier = Notifier A` + - `event_key = mission.created` + - `mission_category = 生产` + - `is_enabled = True` + +2. 创建 `NotifierRoute B1` + - `notifier = Notifier B` + - `event_key = mission.created` + - `mission_category = 空` + - `is_enabled = True` + +这样配置后: + +- 生产分类任务创建时,优先命中生产群路由 +- 其他分类任务创建时,走管理群的通配路由 + +## 8. 典型配置示例 + +### 8.1 示例一:所有任务完成统一发管理群 + +`Notifier` - `name`: `任务完成通知-管理群` -- `event_key`: `mission.completed` - `channel`: `wecom_webhook` - `template_key`: `mission_completed` - `is_enabled`: 勾选 -### 6.3 示例三:任务取消通知 +`NotifierRoute` -适合发到客服或跟单群。 - -字段建议: - -- `name`: `任务取消通知-客服群` -- `event_key`: `mission.cancelled` -- `channel`: `wecom_webhook` -- `template_key`: `mission_cancelled` +- `event_key`: `mission.completed` +- `mission_category`: 留空 - `is_enabled`: 勾选 -## 7. 一个事件是否可以绑定多个 Notifier +### 8.2 示例二:售后分类任务创建发售后群 -可以。 +`Notifier` -例如同一个商户下,`mission.completed` 可以同时配置: +- `name`: `任务创建通知-售后群` +- `channel`: `wecom_webhook` +- `template_key`: `mission_created` +- `is_enabled`: 勾选 -- 一条发到生产群 -- 一条发到老板群 -- 一条发到客服群 +`NotifierRoute` -只要它们满足: +- `event_key`: `mission.created` +- `mission_category`: `售后` +- `is_enabled`: 勾选 -- `merchant` 相同 -- `event_key` 相同 -- `is_enabled=True` +### 8.3 示例三:同一个事件同时发多个群 -系统就会逐条发送。 +例如 `mission.cancelled` 同时发客服群和管理群: -## 8. 模板如何对应 +- 创建两个不同的 `Notifier` +- 分别为它们配置两条 `event_key = mission.cancelled` 的路由 +- 两条路由都可以是 `mission_category` 为空的通配路由 + +系统会分别发送到两个群。 + +## 9. 模板如何对应 当前系统已内置以下模板: @@ -233,61 +261,84 @@ 管理人员通常只需要填 `template_key`,不需要改代码。 如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。 -## 9. 如何停用某条通知 +## 10. 如何停用 -如果暂时不想让某条通知继续发送,不需要删除,只需要: +### 停用整个通知器 -1. 打开该 `Notifier` +适用于: + +- 这个群临时不用 +- 机器人 key 暂时不可用 +- 该通知器下的所有路由都不想生效 + +操作方式: + +1. 打开 `Notifier` 2. 取消勾选 `is_enabled` 3. 保存 -这样最安全,也方便后续恢复。 +### 停用某一条路由 -## 10. 常见问题 +适用于: -### 10.1 为什么事件发生了,但没有收到通知 +- 只是不想处理某个事件 +- 只是不想处理某个任务分类 + +操作方式: + +1. 打开对应 `NotifierRoute` +2. 取消勾选 `is_enabled` +3. 保存 + +## 11. 常见问题 + +### 11.1 为什么事件发生了,但没有收到通知 请依次检查: -1. `Notifier` 是否已勾选 `is_enabled` -2. `merchant` 是否配置正确 +1. `Notifier` 是否启用 +2. `NotifierRoute` 是否启用 3. `event_key` 是否选对 -4. `template_key` 是否与现有模板匹配 -5. `config.key` 是否填写正确 -6. Celery worker 是否已启动 +4. `mission_category` 是否与实际任务分类匹配 +5. `template_key` 是否对应现有模板 +6. `config.key` 是否填写正确 +7. Celery worker 是否已启动 -### 10.2 为什么同一个事件发了多次 +### 11.2 为什么某个任务分类没有走专门群 -通常是因为配置了多条相同 `merchant + event_key` 的启用状态通知器。 +常见原因: + +1. 没有为该分类配置专用路由 +2. 专用路由被停用 +3. 该任务分类本身不是你以为的那个分类 + +### 11.3 为什么同一个事件发了多次 + +通常是因为配置了多个不同的 `NotifierRoute`,分别绑定到了不同的 `Notifier`。 这不一定是错误,也可能是有意发往多个群。 -如果不希望多发,请检查是否存在重复配置。 +### 11.4 同一个通知器会不会因为“专用路由 + 通配路由”重复发送两次 -### 10.3 是否建议删除 Notifier +不会。 +系统会自动去重,并优先使用更具体的分类路由。 -一般不建议优先删除,建议先停用: +## 12. 管理建议 -- 更安全 -- 方便回滚 -- 方便排查历史配置 - -## 11. 管理建议 - -建议按下面的方式维护: - -- 名称中写清楚用途和群目标 +- 先建 `Notifier`,再建 `NotifierRoute` +- 通知器名称中写清楚目标群 +- 路由备注中写清楚事件和分类用途 - 先停用再删除 -- 一个事件先配一条验证,确认无误后再扩展到多个群 -- `description` 中注明负责人或用途 +- 先配置一条路由做验证,再批量扩展 -## 12. 给管理人员的最简操作结论 +## 13. 最简操作结论 -如果你只想快速配置一条通知,记住这 5 个关键点就够了: +如果你只想快速配置一条通知,记住这 6 个关键点就够了: -1. 选对 `merchant` -2. 选对 `event_key` -3. 选 `channel = wecom_webhook` -4. 填对 `template_key` -5. 在 `config` 填正确的企业微信机器人 `key` +1. 先创建 `Notifier` +2. 填好 `channel` +3. 填好 `template_key` +4. 在 `config` 中填好企业微信机器人 `key` +5. 再创建 `NotifierRoute` +6. 选对 `event_key` 和 `mission_category` -这样保存后,对应事件发生时就会自动发送。 +这样保存后,对应任务事件发生时就会自动按路由发送。 diff --git a/docs/notifier_module_design.md b/docs/notifier_module_design.md index 9c65507..8713bb6 100644 --- a/docs/notifier_module_design.md +++ b/docs/notifier_module_design.md @@ -2,131 +2,118 @@ 本文档面向后端维护者,描述 `notifier` 模块当前的实现决策、已落地范围与后续扩展注意事项。 -## 1. 背景与目标 +## 1. 当前阶段 -项目原有的通知能力主要以企业微信机器人为主,并且配置集中在 `settings.py` 中,属于静态配置方案。 -新的 `mission` 模块希望从一开始就采用: +`notifier` 现已从第一阶段的“`Notifier` 直接绑定 `event_key`”升级为第二阶段的“`Notifier + NotifierRoute`”结构。 -- 独立 Django app -- task 化投递 -- 后台可配置 -- signal 与 notifier 动态绑定 -- 为后续增加其它通知渠道预留统一接口 +这样做的原因是: -因此本次新增独立模块 `notifier`,并先将 `mission` 的新信号通知接入该模块。 +- 一个通知器可能需要绑定多个事件 +- 同一事件需要支持按任务分类路由 +- 同一事件可能同时发往多个不同通知器 +- 后续还可能出现更多路由维度 + +因此当前模块职责被拆成两层: + +- `Notifier`:通知渠道配置、模板配置、启停控制 +- `NotifierRoute`:事件匹配与业务路由规则 ## 2. 当前范围 -本次实现属于 Phase 1,范围有意收敛: +本阶段已实现: -- 已新增独立 app:`notifier` -- 已支持后台配置 `Notifier` -- 已支持按 `event_key` + `merchant` 动态匹配通知器 -- 已支持 Celery task 化派发 -- 已支持模板化内容渲染 -- 已支持第一种渠道:企业微信机器人 webhook -- 已接入 `mission` 的 6 个业务信号 +- 独立 app:`notifier` +- Celery task 化投递 +- 企业微信 webhook 渠道 +- 模板化内容渲染 +- Admin 可配置 +- `NotifierRoute` 事件路由 +- `mission` 相关事件按任务分类路由 -本次**没有**做的内容: +本阶段仍未做: -- 没有改造旧模块的静态通知逻辑 -- 没有引入通知订阅/端点拆表 -- 没有引入通知投递明细表(如 `NotificationDelivery`) -- 没有做数据库级别审计 -- 没有提供对外 API +- 旧模块静态通知逻辑迁移 +- 外部 API +- 通知投递明细表 +- 数据库级别审计 ## 3. 核心模型 -当前模型只有一个主模型:`notifier.Notifier` +### 3.1 Notifier -字段职责如下: +`Notifier` 负责“怎么发”: -- `merchant`: 多商户隔离 -- `name`: 通知器名称,仅要求在同商户内唯一 -- `event_key`: 事件标识,用于和业务 signal 对接 -- `channel`: 通知渠道,当前仅实现 `wecom_webhook` -- `template_key`: 模板标识,对应固定目录中的模板文件 -- `is_enabled`: 启用/停用 -- `config`: 渠道配置,当前主要存放企业微信 webhook key、msgtype、timeout 等 -- `description`: 备注 +- `merchant` +- `name` +- `channel` +- `template_key` +- `is_enabled` +- `config` +- `description` -当前将 `event_key` 直接放在 `Notifier` 上,而没有拆成“事件订阅 + 通知端点”两层,原因是现阶段追求低复杂度、可快速上线。 -后续如果一个通知端点需要订阅多个事件,或者一个事件需要更复杂的启停/优先级/路由策略,再考虑拆模。 +当前 `Notifier` 已不再直接持有 `event_key`。 -## 4. 目录结构 +### 3.2 NotifierRoute -关键文件如下: +`NotifierRoute` 负责“何时发、发给谁”: -- `notifier/models.py` -- `notifier/admin.py` -- `notifier/services.py` -- `notifier/tasks.py` -- `notifier/backends.py` -- `notifier/registry.py` -- `notifier/templates/notifier/events/` +- `merchant` +- `notifier` +- `event_key` +- `mission_category` +- `is_enabled` +- `description` -模板固定目录为: +其中: -`notifier/templates/notifier/events/` +- `mission_category = null` 表示该事件的通配路由 +- `mission_category != null` 表示任务分类专用路由 -当前已提供的模板: +### 3.3 约束设计 -- `mission_created.md` -- `mission_replied.md` -- `mission_completed.md` -- `mission_reply_rejected.md` -- `mission_reopened.md` -- `mission_cancelled.md` +当前约束: -`template_key` 与模板文件名一一对应,例如: +- `Notifier` 在同商户下 `name` 唯一 +- `NotifierRoute` 在同一 `notifier + event_key + mission_category` 下唯一 +- `NotifierRoute` 额外限制同一 `notifier + event_key` 只能有一条通配路由 -- `template_key="mission_created"` -- 模板路径 `notifier/events/mission_created.md` +## 4. 路由匹配规则 -## 5. 调用链路 +当前 `dispatch_notification_event(...)` 的匹配规则为: -当前通知链路为: +1. 先按 `merchant + event_key + route.is_enabled=True + notifier.is_enabled=True` 查路由 +2. 如果 payload 中带有 `category_id` + - 匹配该分类的专用路由 + - 也允许匹配通配路由 +3. 如果 payload 中没有 `category_id` + - 只匹配通配路由 +4. 如果同一个 `Notifier` 同时命中专用路由和通配路由 + - 只保留一条 + - 优先保留专用路由 -1. `mission.services` 在事务提交后发送业务 signal -2. `mission.handlers` 监听 signal -3. handler 将业务对象整理为纯字典 payload -4. handler 调用 `notifier.services.enqueue_notification_event(...)` -5. notifier 通过 Celery task 异步执行投递 -6. task 内部调用 `dispatch_notification_event(...)` -7. 按 `merchant_id + event_key + is_enabled=True` 查询匹配的 `Notifier` -8. 逐个渲染模板并调用对应 backend 的 `notify(...)` -9. 写详细日志 +这样可以同时满足: -这里有两个关键约束: +- 分类专用通知 +- 默认兜底通知 +- 多群并发通知 +- 同一通知器不重复发送 -- handler 只做 payload 组装和入队,不做实际发送 -- task 层才做真正的通知投递 +## 5. 当前调用链 -这样可以保持业务事务与外部通知解耦。 +通知链路如下: -## 6. 渠道抽象 +1. `mission.services` 在事务提交后发 signal +2. `mission.handlers` 构造 payload +3. payload 中已包含 `category_id`、`category_name` +4. handler 调用 `enqueue_notification_event(...)` +5. Celery task 调用 `dispatch_notification_event(...)` +6. notifier 根据 route 匹配命中的 `Notifier` +7. 渲染模板并调用 backend 发送 +8. 记录路由日志和发送日志 -当前 backend 接口约定为: +## 6. 已接入的事件 -- `BaseNotifierBackend.notify(notifier, content, context) -> dict` - -当前已实现: - -- `WeComWebhookNotifierBackend` - -其复用了现有工具: - -- `api_v1.utils.wecom_webhook.send_wecom_webhook_message` - -这样做的原因: - -- 避免重复实现 webhook 发送逻辑 -- 保持旧工具可复用 -- 新模块只负责“编排”和“动态配置” - -## 7. Mission 已接入事件 - -当前 `mission` 已接入以下事件: +当前 `mission` 已接入: - `mission.created` - `mission.replied` @@ -135,95 +122,89 @@ - `mission.reopened` - `mission.cancelled` -对应 handler 在: +这些事件全部支持按任务分类路由。 -- `mission/handlers.py` - -当前 handler 不再只是打日志,而是会构造 payload 并投递到 notifier task。 - -## 8. Admin 配置方式 - -`Notifier` 已接入 Django Admin,可进行: - -- 添加 -- 编辑 -- 删除 -- 启用/停用 - -当前推荐的使用方式: - -1. 在 admin 中新建 `Notifier` -2. 选择所属商户 -3. 选择 `event_key` -4. 选择 `channel=wecom_webhook` -5. 填写 `template_key` -6. 在 `config` 中填写 webhook key 等参数 -7. 启用 `is_enabled` - -当前 `config` 示例: - -```json -{ - "key": "企业微信机器人key", - "msgtype": "markdown", - "timeout_seconds": 10 -} -``` - -## 9. 日志策略 - -本阶段没有引入数据库投递明细表,因此发送明细主要依赖日志。 +## 7. 日志策略 当前日志覆盖以下节点: -- 任务入队 +- 事件入队 +- 没有命中任何可用路由 +- 路由命中成功 - backend 发送成功 -- 单个 notifier 发送成功 -- 单个 notifier 发送失败 -- 某事件无匹配 notifier +- 单个通知发送失败 -这满足当前“先可用、后增强”的目标,也符合“暂不做数据库级审计”的约束。 +当前已增加 route 维度日志,重点字段包括: -## 10. 当前风险与注意点 +- `event_key` +- `merchant_id` +- `route_id` +- `route_mission_category_id` +- `notifier_id` -### 10.1 配置合法性主要依赖管理规范 +## 8. Admin 现状 -当前 `config` 是自由 JSON,没有做更强的结构化校验。 -优点是灵活,缺点是后台录入错误会在发送时才暴露。 +当前后台提供两个对象: -### 10.2 模板标识依赖文件存在 +- `Notifier` +- `NotifierRoute` -`template_key` 对应的模板文件如果不存在,会在发送阶段报错并记录日志。 -这在当前阶段是可接受的,但后续可以考虑在 admin 或 model clean 中增加校验。 +并且: -### 10.3 目前仍是“单对象订阅”模型 +- `Notifier` 页面支持 inline 维护其下路由 +- `NotifierRoute` 也支持单独管理 -一个 `Notifier` 对应一个 `event_key`。 -如果后续出现“一个群同时订阅多个事件”的强需求,可以考虑抽象出 Subscription 层。 +在 `Notifier` inline 场景下,route 的 `merchant` 会自动同步为当前 notifier 的商户,避免管理人员重复录入。 -### 10.4 旧通知逻辑尚未迁移 +## 9. 迁移策略 -当前仅 `mission` 新通知走 `notifier`。 -`printing`、`shipment` 等旧逻辑仍保留原来的静态方式,不应在本次改动中混改。 +本次从旧结构迁到新结构时,做了自动回填: -## 11. 后续建议 +- 对每条旧 `Notifier(event_key=...)` +- 自动创建一条 `NotifierRoute` +- `event_key` 原样继承 +- `mission_category = null` +- `description` 标记为自动迁移生成 -按优先级建议如下: +这样旧配置不会因为结构调整而丢失。 -1. 在 admin 使用中观察 `config` 和 `template_key` 是否已足够稳定 -2. 若 notifier 数量增多,再决定是否拆分“通知端点”与“事件订阅” -3. 若需要追踪投递历史,再增加 `NotificationDelivery` -4. 当 mission 通知稳定后,再考虑逐步迁移新业务节点到 notifier +## 10. 测试覆盖重点 + +当前测试已覆盖: + +- 模板渲染 +- backend 发送 +- 路由按事件匹配 +- 分类专用路由优先于通配路由 +- 无专用路由时回退到通配路由 +- 任务事件 handler 正常入队 + +## 11. 当前风险与注意点 + +### 11.1 当前只对 mission 做了分类路由 + +当前 `mission_category` 是显式写进 `NotifierRoute` 的业务字段。 +这适合当前目标,但如果未来要扩展到其它业务模型的复杂路由,可能需要更抽象的路由条件模型。 + +### 11.2 config 仍是自由 JSON + +后台录入错误仍可能在发送时才暴露。 +当前依赖日志排查,后续可增加更强校验。 + +### 11.3 模板校验仍在发送期暴露 + +`template_key` 如果写错,会在渲染阶段报错并记录日志。 +后续可在 admin 或 model clean 中增强校验。 ## 12. 当前结论 -当前方案已经满足: +当前 `notifier` 已具备: -- 独立模块 -- admin 配置 -- task 化通知 -- 模板化内容 -- 动态 signal -> notifier 路由 -- 后续可扩展到多渠道 +- 通知器配置 +- 路由配置 +- 分类分发 +- 任务事件按分类路由 +- 管理后台操作支持 +- 日志与测试保障 -同时复杂度仍控制在较低水平,适合作为第一阶段正式实现。 +这已经足以支撑你当前提出的“所有任务事件都支持按任务分类路由”的要求。 diff --git a/notifier/admin.py b/notifier/admin.py index b9eccf5..ae3fa56 100644 --- a/notifier/admin.py +++ b/notifier/admin.py @@ -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"] diff --git a/notifier/migrations/0002_notifierroute_refactor.py b/notifier/migrations/0002_notifierroute_refactor.py new file mode 100644 index 0000000..10f01ab --- /dev/null +++ b/notifier/migrations/0002_notifierroute_refactor.py @@ -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"), + ), + ] diff --git a/notifier/models.py b/notifier/models.py index 777e2de..b753fe0 100644 --- a/notifier/models.py +++ b/notifier/models.py @@ -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"), ] diff --git a/notifier/services.py b/notifier/services.py index 1f6300e..fddc081 100644 --- a/notifier/services.py +++ b/notifier/services.py @@ -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), } diff --git a/notifier/templates/notifier/events/mission_cancelled.md b/notifier/templates/notifier/events/mission_cancelled.md index b7ea949..990fd83 100644 --- a/notifier/templates/notifier/events/mission_cancelled.md +++ b/notifier/templates/notifier/events/mission_cancelled.md @@ -1,6 +1,7 @@ 任务已取消 任务ID:{{ mission_id }} +分类:{{ category_name }} 取消人:{{ cancelled_by_name }} 取消时间:{{ cancelled_at }} 任务描述:{{ description }} diff --git a/notifier/templates/notifier/events/mission_completed.md b/notifier/templates/notifier/events/mission_completed.md index 72aea90..4246051 100644 --- a/notifier/templates/notifier/events/mission_completed.md +++ b/notifier/templates/notifier/events/mission_completed.md @@ -1,6 +1,7 @@ 任务已完成 任务ID:{{ mission_id }} +分类:{{ category_name }} 完成人:{{ completed_by_name }} 结束回应ID:{{ reply_id|default:"" }} 结束回应内容:{{ reply_content|default:"" }} diff --git a/notifier/templates/notifier/events/mission_reopened.md b/notifier/templates/notifier/events/mission_reopened.md index 335390f..7d7e5ea 100644 --- a/notifier/templates/notifier/events/mission_reopened.md +++ b/notifier/templates/notifier/events/mission_reopened.md @@ -1,6 +1,7 @@ 任务已重新打开 任务ID:{{ mission_id }} +分类:{{ category_name }} 操作人:{{ reopened_by_name }} 被撤销的结束回应ID:{{ rejected_reply_ids_display }} 任务描述:{{ description }} diff --git a/notifier/templates/notifier/events/mission_replied.md b/notifier/templates/notifier/events/mission_replied.md index 47f21d3..5a3de8a 100644 --- a/notifier/templates/notifier/events/mission_replied.md +++ b/notifier/templates/notifier/events/mission_replied.md @@ -1,6 +1,7 @@ 任务有新回应 任务ID:{{ mission_id }} +分类:{{ category_name }} 回应ID:{{ reply_id }} 回应者:{{ responder_name }} 是否结束任务:{% if ends_task %}是{% else %}否{% endif %} diff --git a/notifier/templates/notifier/events/mission_reply_rejected.md b/notifier/templates/notifier/events/mission_reply_rejected.md index 9af9617..922b3e5 100644 --- a/notifier/templates/notifier/events/mission_reply_rejected.md +++ b/notifier/templates/notifier/events/mission_reply_rejected.md @@ -1,6 +1,7 @@ 任务回应已撤销 任务ID:{{ mission_id }} +分类:{{ category_name }} 回应ID:{{ reply_id }} 撤销人:{{ rejected_by_name }} 撤销原因:{{ reason }} diff --git a/notifier/tests.py b/notifier/tests.py index 9833928..51f6b74 100644 --- a/notifier/tests.py +++ b/notifier/tests.py @@ -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"