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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

335
api_v2/test_mission_api.py Normal file
View File

@@ -0,0 +1,335 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from rest_framework.test import APIClient
from basic_info import models as basic_models
from mission import models as mission_models
def grant_permission(user, codename):
permission = Permission.objects.get(codename=codename)
user.user_permissions.add(permission)
class MissionV2APITest(TestCase):
def setUp(self):
self.client = APIClient()
self.merchant = basic_models.Merchant.objects.create(
name="任务商户",
type=basic_models.MerchantTypeEnum.STORE,
)
self.other_merchant = basic_models.Merchant.objects.create(
name="其他商户",
type=basic_models.MerchantTypeEnum.STORE,
)
self.user = get_user_model().objects.create_user(username="mission-user", password="pass12345")
self.employee = basic_models.Employee.objects.create(
merchant=self.merchant,
sys_user=self.user,
name="任务员工",
)
self.default_category = mission_models.MissionCategory.objects.create(
merchant=self.merchant,
name="通用",
)
self.followup_category = mission_models.MissionCategory.objects.create(
merchant=self.merchant,
name="跟进",
)
self.other_category = mission_models.MissionCategory.objects.create(
merchant=self.other_merchant,
name="通用",
)
self.participant = basic_models.Employee.objects.create(
merchant=self.merchant,
name="参与者",
)
self.other_employee = basic_models.Employee.objects.create(
merchant=self.other_merchant,
name="其他员工",
)
self.client.force_authenticate(user=self.user)
def test_create_mission_uses_default_status_and_current_employee(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "跟进客户问题",
"participant_ids": [self.participant.id],
},
format="json",
)
self.assertEqual(resp.status_code, 201)
mission = mission_models.Mission.objects.get(id=resp.data["id"])
self.assertEqual(mission.merchant, self.merchant)
self.assertEqual(mission.creator, self.employee)
self.assertEqual(mission.category, self.default_category)
self.assertFalse(mission.is_urgent)
self.assertFalse(mission.is_completed)
self.assertFalse(mission.is_cancelled)
self.assertEqual(resp.data["category"], self.default_category.id)
self.assertEqual(resp.data["category_name"], "通用")
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_create_mission_rejects_status_fields(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "非法状态",
"is_completed": True,
},
format="json",
)
self.assertEqual(resp.status_code, 400)
self.assertIn("is_completed", resp.data)
def test_create_mission_rejects_cross_merchant_category(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "非法分类",
"category": self.other_category.id,
},
format="json",
)
self.assertEqual(resp.status_code, 400)
self.assertIn("category", resp.data)
def test_patch_mission_rejects_status_fields(self):
mission = self._create_mission()
resp = self.client.patch(
f"/api/v2/missions/{mission.id}/",
{"is_cancelled": True},
format="json",
)
self.assertEqual(resp.status_code, 400)
mission.refresh_from_db()
self.assertFalse(mission.is_cancelled)
def test_patch_mission_rejects_is_urgent(self):
mission = self._create_mission()
resp = self.client.patch(
f"/api/v2/missions/{mission.id}/",
{"is_urgent": True},
format="json",
)
self.assertEqual(resp.status_code, 400)
mission.refresh_from_db()
self.assertFalse(mission.is_urgent)
def test_patch_mission_updates_normal_fields_and_participants(self):
mission = self._create_mission()
resp = self.client.patch(
f"/api/v2/missions/{mission.id}/",
{
"description": "更新后的任务",
"category": self.followup_category.id,
"participant_ids": [self.participant.id],
},
format="json",
)
self.assertEqual(resp.status_code, 200)
mission.refresh_from_db()
self.assertEqual(mission.description, "更新后的任务")
self.assertEqual(mission.category, self.followup_category)
self.assertEqual(resp.data["category"], self.followup_category.id)
self.assertEqual(resp.data["category_name"], "跟进")
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.participant.id])
def test_list_only_returns_current_merchant_missions(self):
visible = self._create_mission(description="可见任务")
mission_models.Mission.objects.create(
merchant=self.other_merchant,
category=self.other_category,
creator=self.other_employee,
description="不可见任务",
)
resp = self.client.get("/api/v2/missions/")
self.assertEqual(resp.status_code, 200)
self.assertEqual([item["id"] for item in resp.data], [visible.id])
def test_delete_mission_is_not_allowed(self):
mission = self._create_mission()
resp = self.client.delete(f"/api/v2/missions/{mission.id}/")
self.assertEqual(resp.status_code, 405)
def test_reopen_mission_requires_permission(self):
mission = self._create_mission()
reply_resp = self.client.post(
f"/api/v2/missions/{mission.id}/replies/",
{"content": "已处理", "ends_task": True},
format="json",
)
self.assertEqual(reply_resp.status_code, 201)
mission.refresh_from_db()
self.assertTrue(mission.is_completed)
reopen_resp = self.client.post(f"/api/v2/missions/{mission.id}/reopen/", {}, format="json")
self.assertEqual(reopen_resp.status_code, 403)
def test_create_ending_reply_and_reopen_mission(self):
grant_permission(self.user, "reopen_mission")
mission = self._create_mission()
reply_resp = self.client.post(
f"/api/v2/missions/{mission.id}/replies/",
{"content": "已处理", "ends_task": True},
format="json",
)
self.assertEqual(reply_resp.status_code, 201)
mission.refresh_from_db()
self.assertTrue(mission.is_completed)
reopen_resp = self.client.post(f"/api/v2/missions/{mission.id}/reopen/", {}, format="json")
self.assertEqual(reopen_resp.status_code, 200)
mission.refresh_from_db()
reply = mission_models.MissionReply.objects.get(id=reply_resp.data["id"])
self.assertFalse(mission.is_completed)
self.assertFalse(reply.ends_task)
self.assertTrue(reply.is_rejected)
self.assertEqual(reply.rejected_by, self.employee)
def test_reject_reply_requires_permission(self):
mission = self._create_mission()
reply = mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="结束",
ends_task=True,
)
mission.is_completed = True
mission.save(update_fields=["is_completed", "updated_at"])
resp = self.client.post(f"/api/v2/mission-replies/{reply.id}/reject/", {}, format="json")
self.assertEqual(resp.status_code, 403)
def test_reject_reply_api_reopens_mission_when_rejecting_ending_reply(self):
grant_permission(self.user, "reject_mission_reply")
mission = self._create_mission()
reply = mission_models.MissionReply.objects.create(
merchant=self.merchant,
mission=mission,
responder=self.employee,
content="结束",
ends_task=True,
)
mission.is_completed = True
mission.save(update_fields=["is_completed", "updated_at"])
resp = self.client.post(f"/api/v2/mission-replies/{reply.id}/reject/", {}, format="json")
self.assertEqual(resp.status_code, 200)
mission.refresh_from_db()
reply.refresh_from_db()
self.assertFalse(mission.is_completed)
self.assertTrue(reply.is_rejected)
self.assertEqual(reply.rejected_by, self.employee)
def test_cancel_mission_api_records_cancelled_by(self):
mission = self._create_mission()
resp = self.client.post(f"/api/v2/missions/{mission.id}/cancel/", {}, format="json")
self.assertEqual(resp.status_code, 200)
mission.refresh_from_db()
self.assertTrue(mission.is_cancelled)
self.assertEqual(mission.cancelled_by, self.employee)
self.assertIsNotNone(mission.cancelled_at)
def test_set_urgent_api_updates_urgent_status(self):
mission = self._create_mission()
resp = self.client.post(
f"/api/v2/missions/{mission.id}/set-urgent/",
{"is_urgent": True},
format="json",
)
self.assertEqual(resp.status_code, 200)
mission.refresh_from_db()
self.assertTrue(mission.is_urgent)
def test_cross_merchant_mission_detail_returns_404(self):
mission = mission_models.Mission.objects.create(
merchant=self.other_merchant,
category=self.other_category,
creator=self.other_employee,
description="其他商户任务",
)
resp = self.client.get(f"/api/v2/missions/{mission.id}/")
self.assertEqual(resp.status_code, 404)
def test_cross_merchant_participant_is_rejected(self):
resp = self.client.post(
"/api/v2/missions/",
{
"description": "跨商户参与者",
"participant_ids": [self.other_employee.id],
},
format="json",
)
self.assertEqual(resp.status_code, 400)
def test_mission_category_crud(self):
list_resp = self.client.get("/api/v2/mission-categories/")
self.assertEqual(list_resp.status_code, 200)
self.assertEqual(
[item["name"] for item in list_resp.data],
["通用", "跟进"],
)
create_resp = self.client.post(
"/api/v2/mission-categories/",
{"name": "售后"},
format="json",
)
self.assertEqual(create_resp.status_code, 201)
category_id = create_resp.data["id"]
detail_resp = self.client.get(f"/api/v2/mission-categories/{category_id}/")
self.assertEqual(detail_resp.status_code, 200)
self.assertEqual(detail_resp.data["name"], "售后")
patch_resp = self.client.patch(
f"/api/v2/mission-categories/{category_id}/",
{"name": "售后跟进"},
format="json",
)
self.assertEqual(patch_resp.status_code, 200)
self.assertEqual(patch_resp.data["name"], "售后跟进")
delete_resp = self.client.delete(f"/api/v2/mission-categories/{category_id}/")
self.assertEqual(delete_resp.status_code, 204)
def test_delete_used_mission_category_is_rejected(self):
mission = self._create_mission()
resp = self.client.delete(f"/api/v2/mission-categories/{mission.category_id}/")
self.assertEqual(resp.status_code, 400)
def _create_mission(self, description="测试任务"):
return mission_models.Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.employee,
description=description,
)

View File

@@ -1,6 +1,7 @@
from django.urls import path
from api_v2.views import (
ContentTypeListView,
HealthCheckView,
QuickCreateEmployeeUserView,
RoleListView,
@@ -14,6 +15,15 @@ from api_v2.views import (
PlateOrderBatchUpdateView,
PrintingOrderBatchAdvanceRecordsView,
BusinessObjectCloneView,
MissionCancelView,
MissionCategoryDetailView,
MissionCategoryListCreateView,
MissionDetailView,
MissionListCreateView,
MissionReopenView,
MissionReplyListCreateView,
MissionReplyRejectView,
MissionSetUrgentView,
)
from api_v2.views.basic_info import CustomerEmployeeBindingView, MyVisiblePagesView
@@ -33,5 +43,14 @@ urlpatterns = [
path('plate-orders/by-state-status/', PlateOrderByStateStatusView.as_view(), name='api_v2_plate_order_by_state_status'),
path('plate-orders/batch-update/', PlateOrderBatchUpdateView.as_view(), name='api_v2_plate_order_batch_update'),
path('stateflow/business-objects/clone/', BusinessObjectCloneView.as_view(), name='api_v2_stateflow_business_object_clone'),
path('content-types/', ContentTypeListView.as_view(), name='api_v2_content_type_list'),
path('mission-categories/', MissionCategoryListCreateView.as_view(), name='api_v2_mission_category_list_create'),
path('mission-categories/<int:category_id>/', MissionCategoryDetailView.as_view(), name='api_v2_mission_category_detail'),
path('missions/', MissionListCreateView.as_view(), name='api_v2_mission_list_create'),
path('missions/<int:mission_id>/', MissionDetailView.as_view(), name='api_v2_mission_detail'),
path('missions/<int:mission_id>/replies/', MissionReplyListCreateView.as_view(), name='api_v2_mission_reply_list_create'),
path('missions/<int:mission_id>/reopen/', MissionReopenView.as_view(), name='api_v2_mission_reopen'),
path('missions/<int:mission_id>/cancel/', MissionCancelView.as_view(), name='api_v2_mission_cancel'),
path('missions/<int:mission_id>/set-urgent/', MissionSetUrgentView.as_view(), name='api_v2_mission_set_urgent'),
path('mission-replies/<int:reply_id>/reject/', MissionReplyRejectView.as_view(), name='api_v2_mission_reply_reject'),
]

View File

@@ -17,6 +17,18 @@ from .printing import (
PrintingOrderBatchAdvanceRecordsView,
)
from .stateflow import BusinessObjectCloneView
from .mission import (
ContentTypeListView,
MissionCancelView,
MissionCategoryDetailView,
MissionCategoryListCreateView,
MissionDetailView,
MissionListCreateView,
MissionReopenView,
MissionReplyListCreateView,
MissionReplyRejectView,
MissionSetUrgentView,
)
__all__ = [
'HealthCheckView',
@@ -33,5 +45,14 @@ __all__ = [
'PlateOrderBatchUpdateView',
'PrintingOrderBatchAdvanceRecordsView',
'BusinessObjectCloneView',
'MissionCancelView',
'MissionCategoryDetailView',
'MissionCategoryListCreateView',
'MissionDetailView',
'MissionListCreateView',
'MissionReopenView',
'MissionReplyListCreateView',
'MissionReplyRejectView',
'MissionSetUrgentView',
'ContentTypeListView',
]

513
api_v2/views/mission.py Normal file
View File

@@ -0,0 +1,513 @@
from django.contrib.contenttypes.models import ContentType
from django.db import IntegrityError
from django.db.models import Q
from django.db.models import ProtectedError
from django.shortcuts import get_object_or_404
from rest_framework import permissions, serializers, status
from rest_framework.response import Response
from rest_framework.views import APIView
from mission import models as mission_models
from mission import services as mission_services
STATUS_FIELDS = {"is_urgent", "is_completed", "is_cancelled", "cancelled_by", "cancelled_at", "rejected_by", "rejected_at"}
def _get_employee(request):
employee = getattr(request.user, "employee", None)
if employee is None:
raise serializers.ValidationError("当前用户未关联员工")
return employee
def _employee_payload(employee):
if employee is None:
return None
return {
"id": employee.id,
"name": employee.name,
"merchant_id": employee.merchant_id,
}
class MissionWriteSerializer(serializers.Serializer):
description = serializers.CharField(required=False, allow_blank=False)
category = serializers.IntegerField(required=False, min_value=1)
content_type = serializers.IntegerField(required=False, allow_null=True, min_value=1)
content_id = serializers.IntegerField(required=False, allow_null=True, min_value=1)
participant_ids = serializers.ListField(
child=serializers.IntegerField(min_value=1),
required=False,
allow_empty=True,
)
def __init__(self, *args, **kwargs):
self.is_create = kwargs.pop("is_create", False)
super().__init__(*args, **kwargs)
if self.is_create:
self.fields["description"].required = True
def validate_category(self, value):
employee = self.context.get("employee")
if employee is None:
return value
try:
return mission_models.MissionCategory.objects.get(id=value, merchant=employee.merchant)
except mission_models.MissionCategory.DoesNotExist as exc:
raise serializers.ValidationError("任务分类不存在") from exc
def validate(self, attrs):
forbidden_fields = STATUS_FIELDS.intersection(self.initial_data.keys())
if forbidden_fields:
raise serializers.ValidationError({field: "状态字段不能通过普通 CRUD 接口修改" for field in sorted(forbidden_fields)})
content_type_provided = "content_type" in self.initial_data
content_id_provided = "content_id" in self.initial_data
if content_type_provided != content_id_provided:
raise serializers.ValidationError("content_type 与 content_id 必须同时提供或同时省略")
content_type_id = attrs.get("content_type")
if content_type_id is not None:
try:
attrs["content_type"] = ContentType.objects.get(id=content_type_id)
except ContentType.DoesNotExist as exc:
raise serializers.ValidationError({"content_type": "ContentType 不存在"}) from exc
return attrs
class MissionReplyCreateSerializer(serializers.Serializer):
content = serializers.CharField(allow_blank=False)
ends_task = serializers.BooleanField(required=False, default=False)
class MissionUrgentSerializer(serializers.Serializer):
is_urgent = serializers.BooleanField()
class MissionCategoryWriteSerializer(serializers.Serializer):
name = serializers.CharField(allow_blank=False, max_length=50)
def validate_name(self, value):
value = value.strip()
if not value:
raise serializers.ValidationError("分类名称不能为空")
employee = self.context["employee"]
instance = self.context.get("instance")
queryset = mission_models.MissionCategory.objects.filter(
merchant=employee.merchant,
name=value,
)
if instance is not None:
queryset = queryset.exclude(id=instance.id)
if queryset.exists():
raise serializers.ValidationError("分类名称已存在")
return value
class MissionSerializer(serializers.ModelSerializer):
creator = serializers.SerializerMethodField()
cancelled_by = serializers.SerializerMethodField()
participants = serializers.SerializerMethodField()
content_type_label = serializers.SerializerMethodField()
category = serializers.IntegerField(source="category_id", read_only=True)
category_name = serializers.CharField(source="category.name", read_only=True)
has_ending_reply = serializers.BooleanField(read_only=True)
can_reply = serializers.BooleanField(read_only=True)
class Meta:
model = mission_models.Mission
fields = [
"id",
"merchant",
"description",
"category",
"category_name",
"is_urgent",
"is_completed",
"is_cancelled",
"cancelled_at",
"creator",
"cancelled_by",
"participants",
"content_type",
"content_type_label",
"content_id",
"has_ending_reply",
"can_reply",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_creator(self, obj):
return _employee_payload(obj.creator)
def get_cancelled_by(self, obj):
return _employee_payload(obj.cancelled_by)
def get_participants(self, obj):
return [
_employee_payload(participant.employee)
for participant in obj.participants.select_related("employee", "employee__merchant")
]
def get_content_type_label(self, obj):
if obj.content_type_id is None:
return None
return f"{obj.content_type.app_label}.{obj.content_type.model}"
class MissionCategorySerializer(serializers.ModelSerializer):
class Meta:
model = mission_models.MissionCategory
fields = [
"id",
"merchant",
"name",
"created_at",
"updated_at",
]
read_only_fields = fields
class MissionReplySerializer(serializers.ModelSerializer):
responder = serializers.SerializerMethodField()
rejected_by = serializers.SerializerMethodField()
class Meta:
model = mission_models.MissionReply
fields = [
"id",
"mission",
"merchant",
"responder",
"content",
"replied_at",
"ends_task",
"is_rejected",
"rejected_by",
"rejected_at",
"created_at",
"updated_at",
]
read_only_fields = fields
def get_responder(self, obj):
return _employee_payload(obj.responder)
def get_rejected_by(self, obj):
return _employee_payload(obj.rejected_by)
def _mission_queryset_for_employee(employee):
return (
mission_models.Mission.objects.filter(merchant=employee.merchant)
.select_related(
"merchant",
"category",
"creator",
"creator__merchant",
"cancelled_by",
"cancelled_by__merchant",
"content_type",
)
.prefetch_related("participants__employee", "participants__employee__merchant")
.order_by("-created_at", "-id")
)
def _mission_category_queryset_for_employee(employee):
return mission_models.MissionCategory.objects.filter(merchant=employee.merchant).order_by("id")
class MissionCategoryListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
employee = _get_employee(request)
queryset = _mission_category_queryset_for_employee(employee)
return Response(MissionCategorySerializer(queryset, many=True).data)
def post(self, request):
employee = _get_employee(request)
serializer = MissionCategoryWriteSerializer(data=request.data, context={"employee": employee})
serializer.is_valid(raise_exception=True)
try:
category = mission_models.MissionCategory.objects.create(
merchant=employee.merchant,
name=serializer.validated_data["name"],
)
except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionCategorySerializer(category).data, status=status.HTTP_201_CREATED)
class MissionCategoryDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get_object(self, request, category_id):
employee = _get_employee(request)
return get_object_or_404(_mission_category_queryset_for_employee(employee), id=category_id)
def get(self, request, category_id):
category = self.get_object(request, category_id)
return Response(MissionCategorySerializer(category).data)
def patch(self, request, category_id):
employee = _get_employee(request)
category = self.get_object(request, category_id)
serializer = MissionCategoryWriteSerializer(
data=request.data,
partial=True,
context={"employee": employee, "instance": category},
)
serializer.is_valid(raise_exception=True)
if "name" in serializer.validated_data:
category.name = serializer.validated_data["name"]
try:
category.save(update_fields=["name", "updated_at"])
except IntegrityError:
return Response({"name": ["分类名称已存在"]}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionCategorySerializer(category).data)
def delete(self, request, category_id):
category = self.get_object(request, category_id)
try:
category.delete()
except ProtectedError:
return Response({"detail": "任务分类已被使用,不能删除"}, status=status.HTTP_400_BAD_REQUEST)
return Response(status=status.HTTP_204_NO_CONTENT)
class MissionListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
employee = _get_employee(request)
queryset = _mission_queryset_for_employee(employee)
if request.query_params.get("category"):
queryset = queryset.filter(category_id=request.query_params["category"])
for field in ["is_urgent", "is_completed", "is_cancelled"]:
value = request.query_params.get(field)
if value is not None:
queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]})
if request.query_params.get("content_type"):
queryset = queryset.filter(content_type_id=request.query_params["content_type"])
if request.query_params.get("content_id"):
queryset = queryset.filter(content_id=request.query_params["content_id"])
return Response(MissionSerializer(queryset, many=True).data)
def post(self, request):
employee = _get_employee(request)
serializer = MissionWriteSerializer(data=request.data, is_create=True, context={"employee": employee})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
mission = mission_services.create_mission(
creator=employee,
description=data["description"],
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
participant_ids=data.get("participant_ids"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data, status=status.HTTP_201_CREATED)
class MissionDetailView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get_object(self, request, mission_id):
employee = _get_employee(request)
return get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
def get(self, request, mission_id):
mission = self.get_object(request, mission_id)
return Response(MissionSerializer(mission).data)
def patch(self, request, mission_id):
employee = _get_employee(request)
mission = self.get_object(request, mission_id)
serializer = MissionWriteSerializer(data=request.data, partial=True, context={"employee": employee})
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
try:
mission = mission_services.update_mission(
mission=mission,
updated_by=employee,
description=data.get("description"),
category=data.get("category"),
content_type=data.get("content_type"),
content_id=data.get("content_id"),
update_content_object=("content_type" in request.data or "content_id" in request.data),
participant_ids=data.get("participant_ids"),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
def delete(self, request, mission_id):
return Response({"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"}, status=status.HTTP_405_METHOD_NOT_ALLOWED)
class MissionReplyListCreateView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
queryset = (
mission_models.MissionReply.objects.filter(mission=mission)
.select_related("responder", "responder__merchant", "rejected_by", "rejected_by__merchant")
.order_by("replied_at", "id")
)
for field in ["ends_task", "is_rejected"]:
value = request.query_params.get(field)
if value is not None:
queryset = queryset.filter(**{field: value.lower() in ["1", "true", "yes"]})
return Response(MissionReplySerializer(queryset, many=True).data)
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
serializer = MissionReplyCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
reply = mission_services.create_mission_reply(
mission=mission,
responder=employee,
content=serializer.validated_data["content"],
ends_task=serializer.validated_data.get("ends_task", False),
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionReplySerializer(reply).data, status=status.HTTP_201_CREATED)
class MissionReopenView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
if not request.user.has_perm("mission.reopen_mission"):
return Response({"detail": "缺少重新打开任务权限"}, status=status.HTTP_403_FORBIDDEN)
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
try:
mission = mission_services.reopen_mission(mission=mission, reopened_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class MissionCancelView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
try:
mission = mission_services.cancel_mission(mission=mission, cancelled_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class MissionReplyRejectView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, reply_id):
if not request.user.has_perm("mission.reject_mission_reply"):
return Response({"detail": "缺少撤销任务回应权限"}, status=status.HTTP_403_FORBIDDEN)
employee = _get_employee(request)
reply = get_object_or_404(
mission_models.MissionReply.objects.select_related("mission", "responder", "rejected_by").filter(
merchant=employee.merchant,
),
id=reply_id,
)
try:
reply = mission_services.reject_reply(reply=reply, rejected_by=employee)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionReplySerializer(reply).data)
class MissionSetUrgentView(APIView):
permission_classes = [permissions.IsAuthenticated]
def post(self, request, mission_id):
employee = _get_employee(request)
mission = get_object_or_404(_mission_queryset_for_employee(employee), id=mission_id)
serializer = MissionUrgentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
mission = mission_services.set_mission_urgent(
mission=mission,
updated_by=employee,
is_urgent=serializer.validated_data["is_urgent"],
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(MissionSerializer(mission).data)
class ContentTypeListView(APIView):
permission_classes = [permissions.IsAuthenticated]
# Models the frontend is allowed to use as mission content_type targets.
_ALLOWED_CONTENT_TYPES = [
("printing", "plateorder"),
("printing", "printingorder"),
("printing", "printingjob"),
("business", "purchaseorder"),
("business", "presalesorder"),
("business", "salesorder"),
("business", "prepurchaseorder"),
("business", "purchasereturnorder"),
("business", "salesreturnorder"),
("business", "paymentorder"),
("business", "receiptorder"),
("stock", "transferorder"),
("shipment", "shipment"),
("stateflow", "process"),
]
# Human-readable labels keyed by (app_label, model).
_LABELS = {
("printing", "plateorder"): "开版单",
("printing", "printingorder"): "印刷单",
("printing", "printingjob"): "印刷任务",
("business", "purchaseorder"): "采购单",
("business", "presalesorder"): "预销售单",
("business", "salesorder"): "销售单",
("business", "prepurchaseorder"): "预采购单",
("business", "purchasereturnorder"): "采购退货单",
("business", "salesreturnorder"): "销售退货单",
("business", "paymentorder"): "付款单",
("business", "receiptorder"): "收款单",
("stock", "transferorder"): "调拨单",
("shipment", "shipment"): "发货单",
("stateflow", "process"): "工艺流程实例",
}
def get(self, request):
filters = Q()
for app_label, model in self._ALLOWED_CONTENT_TYPES:
filters |= Q(app_label=app_label, model=model)
cts = ContentType.objects.filter(filters).order_by("app_label", "model")
data = [
{
"id": ct.id,
"app_label": ct.app_label,
"model": ct.model,
"label": f"{ct.app_label}.{ct.model}",
"name": self._LABELS.get((ct.app_label, ct.model), ct.model),
}
for ct in cts
]
return Response(data)

437
docs/api_v2_mission_api.md Normal file
View File

@@ -0,0 +1,437 @@
# API v2 Mission 任务模块接口文档
本文档面向前端,描述 `mission` 中立任务模块的 API。
## 基本约定
- Base URL: `/api/v2`
- 认证:所有接口都需要登录。
- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。
- 多商户隔离:所有任务、任务分类、参与者、回应都只允许访问当前员工所属商户的数据。
- 删除能力:不提供任务删除接口;需要结束业务时使用 `cancel` 取消任务。
- 状态字段:普通创建/更新接口不允许修改状态字段,状态变化统一走独立接口。
普通 CRUD 禁止提交这些字段:
| 字段 | 说明 |
|------|------|
| `is_urgent` | 是否紧急,使用 `set-urgent` 接口修改 |
| `is_completed` | 是否完成,由结束回应或 reopen/reject 流程维护 |
| `is_cancelled` | 是否取消,使用 `cancel` 接口修改 |
| `cancelled_by` | 取消人,由后端写入 |
| `cancelled_at` | 取消时间,由后端写入 |
| `rejected_by` | 撤销人,由后端写入 |
| `rejected_at` | 撤销时间,由后端写入 |
## content_type 说明
`content_type``content_id` 是可选的"关联业务对象"字段,用于把任务挂靠到系统中的某个具体业务单据或对象上。
### content_type 是什么
`content_type` 是一个整数,对应后端数据库 `django_content_type` 表的主键(`id`)。
该表记录了系统中所有 Django 模型的元信息,每一行代表一个模型,格式为 `app_label` + `model`
**前端不应硬编码 content_type 整数 ID**,因为这个 ID 在不同部署环境中可能不同。
正确做法:调用 `/api/v2/content-types/` 接口(见下方"查询可用 content_type"一节)获取当前环境的实际 ID。
### content_type_label 字段
响应体中的 `content_type_label` 字段是人类可读的模型标识,格式为 `"app_label.model"`,例如:
- `"printing.plateorder"` — 开版单
- `"printing.printingjob"` — 印刷任务
- `"business.purchaseorder"` — 采购单
该字段**只读**,用于前端展示或调试,不作为提交 content_type 时的值。
### 当前系统中可关联的主要业务对象
| `content_type_label` | 中文说明 |
|---|---|
| `printing.plateorder` | 开版单 |
| `printing.printingorder` | 印刷单 |
| `printing.printingjob` | 印刷任务 |
| `business.purchaseorder` | 采购单 |
| `business.presalesorder` | 预销售单 |
| `business.salesorder` | 销售单 |
| `business.prepurchaseorder` | 预采购单 |
| `business.purchasereturnorder` | 采购退货单 |
| `business.salesreturnorder` | 销售退货单 |
| `business.paymentorder` | 付款单 |
| `business.receiptorder` | 收款单 |
| `stock.transferorder` | 调拨单 |
| `shipment.shipment` | 发货单 |
| `stateflow.process` | 工艺流程实例 |
> 上述列表是"有意义的"业务关联对象;系统本身不限制可关联的模型类型,技术上任何有效 ContentType ID 都被接受。
### 查询可用 content_type
- URL: `/api/v2/content-types/`
- Method: `GET`
响应示例:
```json
[
{"id": 7, "app_label": "business", "model": "paymentorder", "label": "business.paymentorder", "name": "付款单"},
{"id": 8, "app_label": "business", "model": "presalesorder", "label": "business.presalesorder", "name": "预销售单"},
...
]
```
前端应在运行时调用此接口获取 `id`,不应硬编码,因为不同部署环境的 ID 可能不同。
## 数据结构
### MissionCategory
```json
{
"id": 1,
"merchant": 10,
"name": "通用",
"created_at": "2026-04-10T12:00:00+08:00",
"updated_at": "2026-04-10T12:00:00+08:00"
}
```
### Mission
```json
{
"id": 1,
"merchant": 10,
"description": "跟进客户问题",
"category": 1,
"category_name": "通用",
"is_urgent": false,
"is_completed": false,
"is_cancelled": false,
"cancelled_at": null,
"creator": {
"id": 20,
"name": "张三",
"merchant_id": 10
},
"cancelled_by": null,
"participants": [
{
"id": 21,
"name": "李四",
"merchant_id": 10
}
],
"content_type": null,
"content_type_label": null,
"content_id": null,
"has_ending_reply": false,
"can_reply": true,
"created_at": "2026-04-10T12:00:00+08:00",
"updated_at": "2026-04-10T12:00:00+08:00"
}
```
### MissionReply
```json
{
"id": 100,
"mission": 1,
"merchant": 10,
"responder": {
"id": 20,
"name": "张三",
"merchant_id": 10
},
"content": "已处理",
"replied_at": "2026-04-10T12:10:00+08:00",
"ends_task": true,
"is_rejected": false,
"rejected_by": null,
"rejected_at": null,
"created_at": "2026-04-10T12:10:00+08:00",
"updated_at": "2026-04-10T12:10:00+08:00"
}
```
## 任务分类列表
- URL: `/api/v2/mission-categories/`
- Method: `GET`
响应:`MissionCategory[]`
## 创建任务分类
- URL: `/api/v2/mission-categories/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `name` | string | 是 | 分类名称,同商户下唯一 |
请求示例:
```json
{
"name": "售后"
}
```
成功响应:`201 Created`,返回 `MissionCategory`
## 任务分类详情
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `GET`
成功响应:`MissionCategory`
## 更新任务分类
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `PATCH`
允许更新:
| 参数 | 类型 | 说明 |
|------|------|------|
| `name` | string | 分类名称,同商户下唯一 |
成功响应:`MissionCategory`
## 删除任务分类
- URL: `/api/v2/mission-categories/<category_id>/`
- Method: `DELETE`
说明:
- 如果该分类已经被任务使用,则删除会被拒绝,返回 `400`
成功响应:`204 No Content`
## 任务列表
- URL: `/api/v2/missions/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `category` | int | 任务分类 ID |
| `is_urgent` | boolean | `true` / `false` |
| `is_completed` | boolean | `true` / `false` |
| `is_cancelled` | boolean | `true` / `false` |
| `content_type` | int | Django ContentType ID |
| `content_id` | int | 关联业务对象 ID |
响应:`Mission[]`
## 创建任务
- URL: `/api/v2/missions/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `description` | string | 是 | 任务描述 |
| `category` | int | 否 | 任务分类 ID不传时默认使用当前商户下名称为“通用”的分类不存在则自动创建 |
| `content_type` | int/null | 否 | Django ContentType ID必须与 `content_id` 同时提供或同时省略 |
| `content_id` | int/null | 否 | 关联业务对象 ID必须与 `content_type` 同时提供或同时省略 |
| `participant_ids` | int[] | 否 | 参与者员工 ID 列表,必须属于当前商户 |
说明:
- `creator``merchant` 由当前登录用户的 employee 自动写入
- `category_name` 为只读字段,由后端根据分类表返回
- `is_urgent``is_completed``is_cancelled` 均按默认值创建,不接受请求参数
- 若关联对象存在 `merchant_id` 字段,后端会校验它必须属于当前商户
请求示例:
```json
{
"description": "跟进客户问题",
"category": 1,
"participant_ids": [21, 22]
}
```
成功响应:`201 Created`,返回 `Mission`
## 任务详情
- URL: `/api/v2/missions/<mission_id>/`
- Method: `GET`
成功响应:`Mission`
跨商户访问返回 `404`
## 更新任务
- URL: `/api/v2/missions/<mission_id>/`
- Method: `PATCH`
允许更新:
| 参数 | 类型 | 说明 |
|------|------|------|
| `description` | string | 任务描述 |
| `category` | int | 任务分类 ID |
| `content_type` | int/null | 关联对象类型;必须与 `content_id` 同时提供 |
| `content_id` | int/null | 关联对象 ID必须与 `content_type` 同时提供 |
| `participant_ids` | int[] | 重置参与者列表 |
禁止更新状态字段,见“基本约定”
成功响应:`Mission`
## 删除任务
- URL: `/api/v2/missions/<mission_id>/`
- Method: `DELETE`
当前不提供删除能力,固定返回:
```json
{
"detail": "Mission 删除接口未提供,请使用 cancel 接口取消任务"
}
```
HTTP 状态码:`405 Method Not Allowed`
## 获取任务回应列表
- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `GET`
查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `ends_task` | boolean | `true` / `false`,筛选是否为结束回应 |
| `is_rejected` | boolean | `true` / `false`,筛选是否已被撤销 |
响应:`MissionReply[]`,按 `replied_at` 升序排列
## 创建任务回应
- URL: `/api/v2/missions/<mission_id>/replies/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `content` | string | 是 | 回应内容 |
| `ends_task` | boolean | 否 | 是否结束任务,默认 `false` |
说明:
- `responder` 使用当前登录用户的 employee
- 如果 `ends_task=true`,后端会同步设置 `Mission.is_completed=true`
- 已取消任务、或已有有效结束回应的任务不允许继续回应
成功响应:`201 Created`,返回 `MissionReply`
## 重新打开任务
- URL: `/api/v2/missions/<mission_id>/reopen/`
- Method: `POST`
- 额外权限:`mission.reopen_mission`
请求体可为空:
```json
{}
```
前置条件:
- 当前用户必须具备 `mission.reopen_mission`
- 任务未取消
- 任务已完成
- 任务存在有效的 `ends_task=true` 回应
执行效果:
- `Mission.is_completed=false`
- 相关结束回应被标记为 `ends_task=false`
- 相关结束回应记录 `is_rejected=true``rejected_by=当前员工``rejected_at=当前时间`
成功响应:`Mission`
无权限响应:`403 Forbidden`
## 取消任务
- URL: `/api/v2/missions/<mission_id>/cancel/`
- Method: `POST`
请求体可为空:
```json
{}
```
执行效果:
- `Mission.is_cancelled=true`
- `Mission.cancelled_by=当前员工`
- `Mission.cancelled_at=当前时间`
- 不强行修改 `Mission.is_completed`
成功响应:`Mission`
## 设置紧急状态
- URL: `/api/v2/missions/<mission_id>/set-urgent/`
- Method: `POST`
请求参数:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `is_urgent` | boolean | 是 | 新的紧急状态 |
请求示例:
```json
{
"is_urgent": true
}
```
成功响应:`Mission`
## 撤销任务回应
- URL: `/api/v2/mission-replies/<reply_id>/reject/`
- Method: `POST`
- 额外权限:`mission.reject_mission_reply`
请求体可为空:
```json
{}
```
执行效果:
- 当前回应标记为 `is_rejected=true`
- 当前回应的 `rejected_by``rejected_at` 由后端写入
- 如果当前回应原本是唯一有效的结束回应,则对应任务会被重新置为未完成
成功响应:`MissionReply`
无权限响应:`403 Forbidden`

View File

@@ -0,0 +1,336 @@
# Mission 模块内部设计说明
本文档面向后端维护者,不作为对外 API 文档。
## 模块定位
`mission` 是一个中立任务及跟进模块,用于承载系统内任意业务对象上的任务、参与者和回应。
命名使用 `Mission`,不使用 `Task`,主要原因是避免和 Celery task 以及 Python/业务语义中的 task 混淆。
## 当前模型
核心模型:
- `Mission`
- `MissionParticipant`
- `MissionReply`
### Mission
职责:
- 表示一个任务。
- 可选关联任意业务对象。
- 保存任务状态。
- 保存创建人、取消人、所属商户。
关键设计:
- `merchant` 是必填外键,用于严格多商户隔离。
- `creator` 指向 `basic_info.Employee`,不直接关联 `auth.User`
- `cancelled_by` 指向 `basic_info.Employee`,可空。
- `content_type + content_id` 都可空,用 Django `GenericForeignKey` 表示可选业务关联。
- `has_ending_reply` 是计算属性,只统计 `ends_task=True``is_rejected=False` 的回应。
- `can_reply` 是计算属性:任务未取消,且不存在有效结束回应。
自定义权限:
- `mission.reopen_mission`
- `mission.reject_mission_reply`
### MissionParticipant
职责:
- 表示任务参与者。
关键设计:
- 通过中间表维护参与者,而不是裸 ManyToMany。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
- `(mission, employee)` 有唯一约束,避免同一员工重复加入同一任务。
### MissionReply
职责:
- 表示任务回应。
关键设计:
- `responder` 指向 `basic_info.Employee`,不可空。
- `ends_task=True` 表示这条回应触发结束任务。
- `is_rejected/rejected_by/rejected_at` 记录回应撤销行为。
- `merchant` 是冗余必填字段,用于隔离和后续高频查询。
## 多商户隔离
当前策略是“显式 merchant 冗余 + service/API 双层限制”。
模型层:
- `Mission.merchant`
- `MissionParticipant.merchant`
- `MissionReply.merchant`
API 层:
- 所有 mission 查询都限制为 `merchant=request.user.employee.merchant`
- 所有 reply 操作都限制为当前商户。
Service 层:
- 校验操作员工属于任务所属商户。
- 创建/更新关联业务对象时,如果目标对象存在 `merchant_id` 字段,则要求目标对象商户与任务商户一致。
- 设置参与者时,所有参与者必须属于任务所属商户。
说明:
- `GenericForeignKey` 本身没有数据库级外键约束,因此 service 层需要负责对象存在性和商户一致性校验。
- `MissionParticipant.merchant``MissionReply.merchant` 是有意冗余字段,后续代码应通过 service 创建和维护,避免绕过造成数据不一致。
## Service 层现状
当前 service 函数位于 `mission/services.py`
公开业务函数:
- `create_mission(...)`
- `update_mission(...)`
- `set_mission_participants(...)`
- `create_mission_reply(...)`
- `reopen_mission(...)`
- `reject_reply(...)`
- `cancel_mission(...)`
- `set_mission_urgent(...)`
内部辅助函数:
- `_assert_employee_belongs_to_mission(...)`
- `_validate_content_object_merchant(...)`
### create_mission
创建任务。
约定:
- `creator` 必须是 `Employee`
- `merchant` 来自 `creator.merchant`
- `is_urgent/is_completed/is_cancelled` 不接受外部参数,按模型默认值创建。
- 可选设置 `content_type/content_id`
- 可选设置参与者列表。
- 成功提交事务后发送 `mission_created` 信号。
### update_mission
普通任务更新。
允许更新:
- `description`
- `category`
- `content_type/content_id`
- `participant_ids`
不负责更新状态字段。
### set_mission_participants
重置任务参与者。
约定:
- 参与者 ID 会去重。
- 所有参与者必须属于任务所属商户。
- 未在新列表中的旧参与者会被删除。
### create_mission_reply
创建任务回应。
约定:
- `responder` 必须属于任务所属商户。
- 任务必须 `can_reply=True`
- 如果 `ends_task=True`,同步设置 `Mission.is_completed=True`
- 成功提交事务后发送 `mission_replied` 信号。
-`ends_task=True` 且任务从未完成变为完成时,同时发送 `mission_completed` 信号。
### reopen_mission
重新打开任务。
API 层需要先校验权限:
- `mission.reopen_mission`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 任务已完成。
- 存在有效的 `ends_task=True, is_rejected=False` 回应。
执行效果:
- `Mission.is_completed=False`
- 相关结束回应设置为 `ends_task=False`
- 相关结束回应记录 `is_rejected=True``rejected_by=reopened_by``rejected_at=now`
- 成功提交事务后发送 `mission_reopened` 信号。
- 被 reopen 撤销的结束回应会发送 `mission_reply_rejected` 信号,`reason="reopen"`
### reject_reply
撤销任务回应。
API 层需要先校验权限:
- `mission.reject_mission_reply`
Service 层前置条件:
- 操作员工属于任务所属商户。
- 任务未取消。
- 回应未被撤销。
执行效果:
- `MissionReply.ends_task=False`
- `MissionReply.is_rejected=True`
- `MissionReply.rejected_by=rejected_by`
- `MissionReply.rejected_at=now`
- 如果被撤销的是结束回应,且任务没有其他有效结束回应,则同步 `Mission.is_completed=False`
- 成功提交事务后发送 `mission_reply_rejected` 信号,`reason="reject_reply"`
### cancel_mission
取消任务。
约定:
- 操作员工必须属于任务所属商户。
- 已取消任务不能重复取消。
- 只设置 `is_cancelled/cancelled_by/cancelled_at`
- 不强行修改 `is_completed`,展示层应让取消状态优先于完成状态。
- 成功提交事务后发送 `mission_cancelled` 信号。
### set_mission_urgent
设置紧急状态。
约定:
- 操作员工必须属于任务所属商户。
- `is_urgent` 不允许通过普通 CRUD 更新,只能通过独立状态接口更新。
## 领域信号
信号定义位于 `mission/signals.py`,空 handler 接入口位于 `mission/handlers.py`,注册逻辑位于 `mission/apps.py`
发送原则:
- 只从 service 层发送,不从 model `post_save` 自动发送。
- 使用 `transaction.on_commit(...)`,确保事务提交成功后 handler 才运行。
- signal 发送异常只记录日志,不反向影响主业务流程。
当前信号:
| 信号 | sender | 触发时机 | 主要 payload |
|------|--------|----------|--------------|
| `mission_created` | `Mission` | 任务创建成功 | `instance`, `created_by` |
| `mission_replied` | `MissionReply` | 任务回应创建成功 | `instance`, `mission`, `responder` |
| `mission_completed` | `Mission` | 结束回应使任务变为完成 | `instance`, `completed_by`, `reply` |
| `mission_reply_rejected` | `MissionReply` | 回应被撤销 | `instance`, `mission`, `rejected_by`, `reason` |
| `mission_reopened` | `Mission` | 任务被 reopen | `instance`, `reopened_by`, `rejected_reply_ids` |
| `mission_cancelled` | `Mission` | 任务被取消 | `instance`, `cancelled_by` |
`mission_reply_rejected.reason` 当前取值:
- `reject_reply`:显式调用 `reject_reply(...)` 撤销回应。
- `reopen``reopen_mission(...)` 内部撤销结束回应。
## API 层现状
API 位于 `api_v2/views/mission.py`,路由位于 `api_v2/urls.py`
对外接口文档见:
- `docs/api_v2_mission_api.md`
当前 API 风格沿用 `api_v2` 现有结构:显式 `APIView + path(...)`,未引入 ViewSet/Router。
普通 CRUD
- `GET /api/v2/mission-categories/`
- `POST /api/v2/mission-categories/`
- `GET /api/v2/mission-categories/<category_id>/`
- `PATCH /api/v2/mission-categories/<category_id>/`
- `DELETE /api/v2/mission-categories/<category_id>/`
- `GET /api/v2/missions/`
- `POST /api/v2/missions/`
- `GET /api/v2/missions/<mission_id>/`
- `PATCH /api/v2/missions/<mission_id>/`
- `DELETE /api/v2/missions/<mission_id>/` 返回 405不提供删除能力
状态和 service 对应接口:
- `POST /api/v2/missions/<mission_id>/replies/`
- `POST /api/v2/missions/<mission_id>/reopen/`
- `POST /api/v2/missions/<mission_id>/cancel/`
- `POST /api/v2/missions/<mission_id>/set-urgent/`
- `POST /api/v2/mission-replies/<reply_id>/reject/`
## 当前测试
测试文件:
- `mission/tests.py`
- `api_v2/test_mission_api.py`
覆盖重点:
- 参与者便捷方法。
- 创建结束回应时任务自动完成。
- reopen 撤销结束回应。
- cancel 记录取消人和取消时间。
- reject reply 撤销回应并按需要恢复任务未完成。
- 普通 CRUD 禁止状态字段。
- 多商户隔离。
- reopen/reject 权限校验。
## 当前覆盖率
最近一次统计命令:
```bash
docker compose exec -T -e DB_HOST=postgres -e DB_PORT=5432 web \
uv run coverage run --source=mission manage.py test --keepdb --noinput \
api_v2.test_mission_api mission
docker compose exec -T web uv run coverage report -m
```
统计结果:
| 范围 | 覆盖率 |
|------|--------|
| `mission` 总体 | 96% |
| `mission/models.py` | 95% |
| `mission/services.py` | 99% |
| `mission/admin.py` | 100% |
| `mission/tests.py` | 100% |
说明:
- 当前按 `--source=mission` 统计,只计算 `mission` 模块本身,不包含 `api_v2/views/mission.py`
- 当前 `mission/services.py` 覆盖率为 99%,剩余未覆盖为 signal 发送异常保护分支。
- `mission` 总体剩余未覆盖主要来自迁移回填函数分支和空的 `mission/views.py`
## 已知边界
- 当前没有 Mission 的前端分页;列表直接返回数组。若任务量增大,应补分页。
- 当前 `MissionCategory` 已独立成表Mission 对外返回 `category=id``category_name`
- 当前没有删除能力,取消是唯一业务关闭入口之一。
- 当前 `GenericForeignKey` 只做对象存在性与可选商户一致性校验,不做目标对象的复杂业务权限校验。

View File

@@ -0,0 +1,293 @@
# Notifier 管理人员配置说明
本文档面向后台管理人员,说明如何在 Django Admin 中配置 `Notifier`,让系统在指定业务事件发生时自动发送通知。
## 1. Notifier 是什么
`Notifier` 可以理解为一条“通知规则”。
当某个业务事件发生时,系统会根据以下条件查找可用的通知器:
- 商户一致
- `event_key` 一致
- `is_enabled=True`
找到后,系统会自动:
1. 使用对应模板渲染消息内容
2. 按配置的渠道发送
3. 记录发送日志
当前已支持的渠道:
- 企业微信机器人 `wecom_webhook`
## 2. 当前已支持的事件
目前 `mission` 模块已接入以下事件:
- `mission.created`:任务创建
- `mission.replied`:任务有新回应
- `mission.completed`:任务完成
- `mission.reply_rejected`:任务回应被撤销
- `mission.reopened`:任务被重新打开
- `mission.cancelled`:任务被取消
如果要让某个事件发送通知,只需要在后台新增对应 `event_key``Notifier`
## 3. 在哪里配置
进入 Django Admin 后,找到:
- `通知器`
然后点击“新增”即可。
## 4. 字段说明
创建 `Notifier` 时,需要填写以下字段。
### 4.1 merchant
所属商户。
通知器只会匹配当前商户下发生的事件。
不同商户如果都需要通知,需要分别创建各自的 `Notifier`
### 4.2 name
通知器名称,仅用于后台识别和管理。
建议命名方式:
- `任务创建通知-生产群`
- `任务完成通知-老板群`
- `任务取消通知-客服群`
同一商户下名称不能重复。
### 4.3 event_key
要监听的业务事件标识。
这是最核心的绑定字段。
它决定这条 `Notifier` 绑定到哪个业务信号。
例如:
- `mission.completed` 表示“任务完成时发送”
- `mission.cancelled` 表示“任务取消时发送”
### 4.4 channel
通知渠道。
当前固定选:
- `wecom_webhook`
### 4.5 template_key
消息模板标识。
系统会根据这个字段去固定目录查找模板文件。
当前模板目录为:
`notifier/templates/notifier/events/`
例如:
- `template_key = mission_completed`
- 对应模板文件:`notifier/templates/notifier/events/mission_completed.md`
### 4.6 is_enabled
是否启用。
- 勾选:该 `Notifier` 生效
- 不勾选:该 `Notifier` 不会参与匹配和发送
### 4.7 config
渠道配置,使用 JSON 格式填写。
当前企业微信机器人建议配置如下:
```json
{
"key": "你的企业微信机器人key",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
字段说明:
- `key`:企业微信机器人 webhook key
- `msgtype`:消息类型,建议用 `markdown`
- `timeout_seconds`:请求超时时间,单位秒
### 4.8 description
备注说明,非必填。
建议写清楚这条通知器的用途,例如:
- `用于生产部任务完成通知`
- `用于客服查看任务取消`
## 5. 配置步骤
以“任务完成时发送企业微信通知”为例:
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. 推荐配置示例
### 6.1 示例一:任务创建通知
适合发到内部任务协作群。
字段建议:
- `name`: `任务创建通知-协作群`
- `event_key`: `mission.created`
- `channel`: `wecom_webhook`
- `template_key`: `mission_created`
- `is_enabled`: 勾选
`config` 示例:
```json
{
"key": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"msgtype": "markdown",
"timeout_seconds": 10
}
```
### 6.2 示例二:任务完成通知
适合发到管理群或老板群。
字段建议:
- `name`: `任务完成通知-管理群`
- `event_key`: `mission.completed`
- `channel`: `wecom_webhook`
- `template_key`: `mission_completed`
- `is_enabled`: 勾选
### 6.3 示例三:任务取消通知
适合发到客服或跟单群。
字段建议:
- `name`: `任务取消通知-客服群`
- `event_key`: `mission.cancelled`
- `channel`: `wecom_webhook`
- `template_key`: `mission_cancelled`
- `is_enabled`: 勾选
## 7. 一个事件是否可以绑定多个 Notifier
可以。
例如同一个商户下,`mission.completed` 可以同时配置:
- 一条发到生产群
- 一条发到老板群
- 一条发到客服群
只要它们满足:
- `merchant` 相同
- `event_key` 相同
- `is_enabled=True`
系统就会逐条发送。
## 8. 模板如何对应
当前系统已内置以下模板:
- `mission_created`
- `mission_replied`
- `mission_completed`
- `mission_reply_rejected`
- `mission_reopened`
- `mission_cancelled`
管理人员通常只需要填 `template_key`,不需要改代码。
如果后续要新增模板内容或调整文案,需要由开发人员修改模板文件。
## 9. 如何停用某条通知
如果暂时不想让某条通知继续发送,不需要删除,只需要:
1. 打开该 `Notifier`
2. 取消勾选 `is_enabled`
3. 保存
这样最安全,也方便后续恢复。
## 10. 常见问题
### 10.1 为什么事件发生了,但没有收到通知
请依次检查:
1. `Notifier` 是否已勾选 `is_enabled`
2. `merchant` 是否配置正确
3. `event_key` 是否选对
4. `template_key` 是否与现有模板匹配
5. `config.key` 是否填写正确
6. Celery worker 是否已启动
### 10.2 为什么同一个事件发了多次
通常是因为配置了多条相同 `merchant + event_key` 的启用状态通知器。
这不一定是错误,也可能是有意发往多个群。
如果不希望多发,请检查是否存在重复配置。
### 10.3 是否建议删除 Notifier
一般不建议优先删除,建议先停用:
- 更安全
- 方便回滚
- 方便排查历史配置
## 11. 管理建议
建议按下面的方式维护:
- 名称中写清楚用途和群目标
- 先停用再删除
- 一个事件先配一条验证,确认无误后再扩展到多个群
- `description` 中注明负责人或用途
## 12. 给管理人员的最简操作结论
如果你只想快速配置一条通知,记住这 5 个关键点就够了:
1. 选对 `merchant`
2. 选对 `event_key`
3.`channel = wecom_webhook`
4. 填对 `template_key`
5.`config` 填正确的企业微信机器人 `key`
这样保存后,对应事件发生时就会自动发送。

View File

@@ -0,0 +1,229 @@
# Notifier 模块实现说明
本文档面向后端维护者,描述 `notifier` 模块当前的实现决策、已落地范围与后续扩展注意事项。
## 1. 背景与目标
项目原有的通知能力主要以企业微信机器人为主,并且配置集中在 `settings.py` 中,属于静态配置方案。
新的 `mission` 模块希望从一开始就采用:
- 独立 Django app
- task 化投递
- 后台可配置
- signal 与 notifier 动态绑定
- 为后续增加其它通知渠道预留统一接口
因此本次新增独立模块 `notifier`,并先将 `mission` 的新信号通知接入该模块。
## 2. 当前范围
本次实现属于 Phase 1范围有意收敛
- 已新增独立 app`notifier`
- 已支持后台配置 `Notifier`
- 已支持按 `event_key` + `merchant` 动态匹配通知器
- 已支持 Celery task 化派发
- 已支持模板化内容渲染
- 已支持第一种渠道:企业微信机器人 webhook
- 已接入 `mission` 的 6 个业务信号
本次**没有**做的内容:
- 没有改造旧模块的静态通知逻辑
- 没有引入通知订阅/端点拆表
- 没有引入通知投递明细表(如 `NotificationDelivery`
- 没有做数据库级别审计
- 没有提供对外 API
## 3. 核心模型
当前模型只有一个主模型:`notifier.Notifier`
字段职责如下:
- `merchant`: 多商户隔离
- `name`: 通知器名称,仅要求在同商户内唯一
- `event_key`: 事件标识,用于和业务 signal 对接
- `channel`: 通知渠道,当前仅实现 `wecom_webhook`
- `template_key`: 模板标识,对应固定目录中的模板文件
- `is_enabled`: 启用/停用
- `config`: 渠道配置,当前主要存放企业微信 webhook key、msgtype、timeout 等
- `description`: 备注
当前将 `event_key` 直接放在 `Notifier` 上,而没有拆成“事件订阅 + 通知端点”两层,原因是现阶段追求低复杂度、可快速上线。
后续如果一个通知端点需要订阅多个事件,或者一个事件需要更复杂的启停/优先级/路由策略,再考虑拆模。
## 4. 目录结构
关键文件如下:
- `notifier/models.py`
- `notifier/admin.py`
- `notifier/services.py`
- `notifier/tasks.py`
- `notifier/backends.py`
- `notifier/registry.py`
- `notifier/templates/notifier/events/`
模板固定目录为:
`notifier/templates/notifier/events/`
当前已提供的模板:
- `mission_created.md`
- `mission_replied.md`
- `mission_completed.md`
- `mission_reply_rejected.md`
- `mission_reopened.md`
- `mission_cancelled.md`
`template_key` 与模板文件名一一对应,例如:
- `template_key="mission_created"`
- 模板路径 `notifier/events/mission_created.md`
## 5. 调用链路
当前通知链路为:
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 层才做真正的通知投递
这样可以保持业务事务与外部通知解耦。
## 6. 渠道抽象
当前 backend 接口约定为:
- `BaseNotifierBackend.notify(notifier, content, context) -> dict`
当前已实现:
- `WeComWebhookNotifierBackend`
其复用了现有工具:
- `api_v1.utils.wecom_webhook.send_wecom_webhook_message`
这样做的原因:
- 避免重复实现 webhook 发送逻辑
- 保持旧工具可复用
- 新模块只负责“编排”和“动态配置”
## 7. Mission 已接入事件
当前 `mission` 已接入以下事件:
- `mission.created`
- `mission.replied`
- `mission.completed`
- `mission.reply_rejected`
- `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. 日志策略
本阶段没有引入数据库投递明细表,因此发送明细主要依赖日志。
当前日志覆盖以下节点:
- 任务入队
- backend 发送成功
- 单个 notifier 发送成功
- 单个 notifier 发送失败
- 某事件无匹配 notifier
这满足当前“先可用、后增强”的目标,也符合“暂不做数据库级审计”的约束。
## 10. 当前风险与注意点
### 10.1 配置合法性主要依赖管理规范
当前 `config` 是自由 JSON没有做更强的结构化校验。
优点是灵活,缺点是后台录入错误会在发送时才暴露。
### 10.2 模板标识依赖文件存在
`template_key` 对应的模板文件如果不存在,会在发送阶段报错并记录日志。
这在当前阶段是可接受的,但后续可以考虑在 admin 或 model clean 中增加校验。
### 10.3 目前仍是“单对象订阅”模型
一个 `Notifier` 对应一个 `event_key`
如果后续出现“一个群同时订阅多个事件”的强需求,可以考虑抽象出 Subscription 层。
### 10.4 旧通知逻辑尚未迁移
当前仅 `mission` 新通知走 `notifier`
`printing``shipment` 等旧逻辑仍保留原来的静态方式,不应在本次改动中混改。
## 11. 后续建议
按优先级建议如下:
1. 在 admin 使用中观察 `config``template_key` 是否已足够稳定
2. 若 notifier 数量增多,再决定是否拆分“通知端点”与“事件订阅”
3. 若需要追踪投递历史,再增加 `NotificationDelivery`
4. 当 mission 通知稳定后,再考虑逐步迁移新业务节点到 notifier
## 12. 当前结论
当前方案已经满足:
- 独立模块
- admin 配置
- task 化通知
- 模板化内容
- 动态 signal -> notifier 路由
- 后续可扩展到多渠道
同时复杂度仍控制在较低水平,适合作为第一阶段正式实现。

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View File

@@ -0,0 +1,146 @@
@startuml
title 中立 mission 及跟进模块类图
skinparam shadowing false
skinparam class {
BackgroundColor #F8FBFF
BorderColor #4C6A92
ArrowColor #4C6A92
FontName Noto Sans CJK SC
}
skinparam note {
BackgroundColor #FFF7E6
BorderColor #B7791F
FontName Noto Sans CJK SC
}
skinparam defaultFontName Noto Sans CJK SC
class "Mission\n任务" as Mission {
+id: BigAutoField
+merchant: Merchant
+description: Text
+category: CharField
+created_at: DateTime
+is_urgent: Boolean = false
+is_completed: Boolean = false
+is_cancelled: Boolean = false
+cancelled_at: DateTime? = null
+content_type: ContentType? = null
+content_id: PositiveBigInteger? = null
+content_object: GenericForeignKey?
+creator: Employee
+cancelled_by: Employee? = null
--
+participants: QuerySet<Employee>
+has_ending_reply: Boolean <<computed>>
+can_reply: Boolean <<computed>>
+get_participants(): QuerySet<Employee>
+filter_participants(...): QuerySet<Employee>
+reopen(): Mission
}
class "MissionParticipant\n任务参与者" as MissionParticipant {
+id: BigAutoField
+merchant: Merchant
+mission: Mission
+employee: Employee
+created_at: DateTime
}
class "MissionReply\n任务回应" as MissionReply {
+id: BigAutoField
+merchant: Merchant
+mission: Mission
+responder: Employee
+content: Text
+replied_at: DateTime
+ends_task: Boolean = false
+is_rejected: Boolean = false
+rejected_by: Employee? = null
+rejected_at: DateTime? = null
}
class "Merchant\nbasic_info.Merchant" as Merchant {
+id: BigAutoField
+name: CharField
}
class "Employee\nbasic_info.Employee" as Employee {
+id: BigAutoField
+name: CharField
+merchant: Merchant
}
class "ContentType\ndjango.contrib.contenttypes" as ContentType {
+id: AutoField
+app_label: CharField
+model: CharField
}
class "Any Business Model\n任意业务模型" as AnyBusinessModel {
+id: ...
}
Mission "1" o-- "0..*" MissionParticipant : participants
Mission "0..*" --> "1" Merchant : merchant
MissionParticipant "0..*" --> "1" Merchant : merchant
MissionReply "0..*" --> "1" Merchant : merchant
MissionParticipant "0..*" --> "1" Employee : employee
Mission "1" --> "1" Employee : creator
Mission "0..*" --> "0..1" Employee : cancelled_by
Mission "1" o-- "0..*" MissionReply : replies
MissionReply "0..*" --> "1" Employee : responder
MissionReply "0..*" --> "0..1" Employee : rejected_by
Mission "0..*" --> "0..1" ContentType : content_type
Mission ..> AnyBusinessModel : content_object\nGenericForeignKey
note right of Mission
content_type 与 content_id 均可为空:
- 为空:任务不绑定具体业务对象
- 非空:任务可关联系统中任意业务模型
所有人物字段均关联 basic_info.Employee
不直接关联 request.user / auth.User。
Mission / MissionParticipant / MissionReply
都冗余保存 merchant用于严格多商户隔离。
end note
note bottom of Mission
参与者通过 MissionParticipant 中间表维护。
Mission 模型应提供便捷方法用于获取和筛选参与者,
但关系本身不直接写成裸 ManyToMany 字段,
方便后续扩展参与者状态、角色、加入时间等信息。
end note
note right of MissionReply
当任意关联回应 ends_task = true 时:
- Mission.has_ending_reply = true
- Mission.can_reply = false
- 后续不允许继续创建 MissionReply
创建 ends_task = true 的回应时,
本模块 service 同步设置:
Mission.is_completed = true
end note
note left of Mission
is_cancelled 默认为 false。
取消任务时写入:
- cancelled_by
- cancelled_at
end note
note bottom of MissionReply
reopen 独立业务函数的前置条件:
- Mission.is_cancelled = false
- Mission.is_completed = true
- 能查询到 ends_task = true 的 MissionReply
reopen 执行效果:
- Mission.is_completed = false
- 对 ends_task = true 的回应设置 ends_task = false
- 同时记录该回应 is_rejected = true、
rejected_by = reopened_by、rejected_at = now
end note
@enduml

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -0,0 +1,155 @@
@startuml
title Notifier 模块一期类图
skinparam shadowing false
skinparam class {
BackgroundColor #F8FBFF
BorderColor #4C6A92
ArrowColor #4C6A92
FontName Noto Sans CJK SC
}
skinparam note {
BackgroundColor #FFF7E6
BorderColor #B7791F
FontName Noto Sans CJK SC
}
skinparam defaultFontName Noto Sans CJK SC
class "Notifier\n通知器配置" as Notifier {
+id: BigAutoField
+merchant: Merchant
+name: CharField
+event_key: CharField
+channel: CharField
+template_key: CharField
+is_enabled: Boolean = true
+config: JSON
+description: Text?
+created_at: DateTime
+updated_at: DateTime
}
class "Merchant\nbasic_info.Merchant" as Merchant {
+id: BigAutoField
+name: CharField
}
class "Mission Signals\n领域信号" as MissionSignals <<service>> {
+mission_created
+mission_replied
+mission_completed
+mission_reply_rejected
+mission_reopened
+mission_cancelled
}
class "MissionHandlers\n信号处理器" as MissionHandlers <<service>> {
+on_mission_created(...)
+on_mission_replied(...)
+on_mission_completed(...)
+on_mission_reply_rejected(...)
+on_mission_reopened(...)
+on_mission_cancelled(...)
}
class "NotifierDispatcher\n通知分发器" as NotifierDispatcher <<service>> {
+dispatch(event_key, merchant_id, payload)
}
class "TemplateRenderer\n模板渲染器" as TemplateRenderer <<service>> {
+render(template_key, payload): dict
}
abstract class "BaseNotifier\n渠道后端抽象" as BaseNotifier {
+channel: str
+notify(endpoint, event_key, payload, rendered): dict
}
class "WeComWebhookNotifier\n企业微信通知器" as WeComWebhookNotifier {
+channel = \"wecom_webhook\"
+notify(endpoint, event_key, payload, rendered): dict
}
class "EmailNotifier\n邮件通知器" as EmailNotifier {
+channel = \"email\"
+notify(endpoint, event_key, payload, rendered): dict
}
class "NotifierRegistry\n通知器注册表" as NotifierRegistry <<service>> {
+get(channel): BaseNotifier
}
class "NotifierTask\n异步任务入口" as NotifierTask <<task>> {
+dispatch_notification_event(...)
}
MissionSignals ..> MissionHandlers : connect
MissionHandlers ..> NotifierTask : on_commit + delay
NotifierTask ..> NotifierDispatcher : dispatch(...)
NotifierDispatcher --> "0..*" Notifier : query by\nmerchant + event_key + is_enabled
NotifierDispatcher ..> TemplateRenderer : render(...)
NotifierDispatcher ..> NotifierRegistry : get(channel)
NotifierRegistry ..> BaseNotifier
BaseNotifier <|-- WeComWebhookNotifier
BaseNotifier <|-- EmailNotifier
Notifier "0..*" --> "1" Merchant : merchant
note right of Notifier
一期简化设计:
- 不拆 Endpoint / Subscription
- 直接把 event_key 绑定在 Notifier 对象上
- 直接把 template_key 配置在 Notifier 对象上
适合通过 Django Admin 做:
- 添加 / 删除
- 启用 / 禁用
- 编辑渠道参数
end note
note bottom of Notifier
config 为 JSON
企业微信示例可包含:
- webhook_key
- mentioned_list
- mentioned_mobile_list
同一商户下可配置多个 Notifier
同一个 event_key 可命中多个 Notifier。
end note
note right of TemplateRenderer
模板文件不落数据库。
Notifier 仅保存 template_key。
模板集中存放在固定目录,例如:
notifier/templates/
end note
note left of MissionHandlers
handler 不直接拼企业微信内容,
也不直接读取 settings.py 中的静态 key。
handler 只负责把领域事件
转成 event_key + payload
再交给 notifier 模块。
end note
note bottom of NotifierDispatcher
一期暂不引入 NotificationDelivery 表。
发送结果通过日志记录即可,
不做数据库级发送审计。
风险:
- 无法在数据库中重放 / 检索历史发送记录
- 排查依赖日志系统
end note
note left of Notifier
设计取舍风险:
把 template_key 配到 Notifier 对象上,
会让“同一渠道目标用于多个事件”时产生配置重复。
但这能显著降低一期复杂度,
且更贴合 Admin 直接维护。
end note
@enduml

View File

@@ -151,6 +151,8 @@ INSTALLED_APPS = [
'api_v2',
'shipment',
'settlement',
'mission',
'notifier',
]
MIDDLEWARE = [

0
mission/__init__.py Normal file
View File

44
mission/admin.py Normal file
View File

@@ -0,0 +1,44 @@
from django.contrib import admin
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
@admin.register(MissionCategory)
class MissionCategoryAdmin(admin.ModelAdmin):
list_display = ["id", "merchant", "name", "created_at"]
list_filter = ["merchant", "created_at"]
search_fields = ["name"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(Mission)
class MissionAdmin(admin.ModelAdmin):
list_display = [
"id",
"merchant",
"category",
"creator",
"is_urgent",
"is_completed",
"is_cancelled",
"created_at",
]
list_filter = ["merchant", "category", "is_urgent", "is_completed", "is_cancelled", "created_at"]
search_fields = ["description", "creator__name"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(MissionParticipant)
class MissionParticipantAdmin(admin.ModelAdmin):
list_display = ["id", "merchant", "mission", "employee", "created_at"]
list_filter = ["merchant", "created_at"]
search_fields = ["employee__name"]
readonly_fields = ["created_at", "updated_at"]
@admin.register(MissionReply)
class MissionReplyAdmin(admin.ModelAdmin):
list_display = ["id", "merchant", "mission", "responder", "ends_task", "is_rejected", "replied_at"]
list_filter = ["merchant", "ends_task", "is_rejected", "replied_at"]
search_fields = ["content", "responder__name"]
readonly_fields = ["created_at", "updated_at"]

50
mission/apps.py Normal file
View File

@@ -0,0 +1,50 @@
from django.apps import AppConfig
class MissionConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'mission'
verbose_name = '任务管理'
def ready(self):
from . import handlers
from .models import Mission, MissionReply
from .signals import (
mission_cancelled,
mission_completed,
mission_created,
mission_reopened,
mission_replied,
mission_reply_rejected,
)
mission_created.connect(
handlers.on_mission_created,
sender=Mission,
dispatch_uid="mission.on_mission_created",
)
mission_replied.connect(
handlers.on_mission_replied,
sender=MissionReply,
dispatch_uid="mission.on_mission_replied",
)
mission_completed.connect(
handlers.on_mission_completed,
sender=Mission,
dispatch_uid="mission.on_mission_completed",
)
mission_reply_rejected.connect(
handlers.on_mission_reply_rejected,
sender=MissionReply,
dispatch_uid="mission.on_mission_reply_rejected",
)
mission_reopened.connect(
handlers.on_mission_reopened,
sender=Mission,
dispatch_uid="mission.on_mission_reopened",
)
mission_cancelled.connect(
handlers.on_mission_cancelled,
sender=Mission,
dispatch_uid="mission.on_mission_cancelled",
)

145
mission/handlers.py Normal file
View File

@@ -0,0 +1,145 @@
import logging
from notifier.models import NotificationEventKeyEnum
from notifier.services import enqueue_notification_event
logger = logging.getLogger(__name__)
def _build_mission_payload(mission) -> dict:
participant_names = list(
mission.get_participants().values_list("employee__name", flat=True)
)
content_type = getattr(mission.content_type, "model", None)
return {
"mission_id": mission.id,
"merchant_id": mission.merchant_id,
"description": mission.description,
"category_id": mission.category_id,
"category_name": mission.category.name,
"is_urgent": mission.is_urgent,
"is_completed": mission.is_completed,
"is_cancelled": mission.is_cancelled,
"creator_id": mission.creator_id,
"creator_name": getattr(mission.creator, "name", ""),
"participant_names": participant_names,
"participant_names_display": "".join(participant_names) if participant_names else "",
"content_type": content_type or "",
"content_id": mission.content_id or "",
}
def _build_reply_payload(reply) -> dict:
return {
"reply_id": reply.id,
"reply_content": reply.content,
"reply_content_short": (reply.content or "")[:100],
"responder_id": reply.responder_id,
"responder_name": getattr(reply.responder, "name", ""),
"ends_task": reply.ends_task,
"is_rejected": reply.is_rejected,
"replied_at": reply.replied_at.isoformat() if reply.replied_at else "",
"rejected_at": reply.rejected_at.isoformat() if reply.rejected_at else "",
}
def _enqueue(*, event_key: str, merchant_id: int, payload: dict) -> None:
task_id = enqueue_notification_event(
event_key=event_key,
merchant_id=merchant_id,
payload=payload,
)
logger.info(
"[mission.handlers] queued notifier event: event_key=%s merchant_id=%s task_id=%s",
event_key,
merchant_id,
task_id,
)
def on_mission_created(sender, instance, created_by=None, **kwargs):
payload = {
**_build_mission_payload(instance),
"created_by_id": getattr(created_by, "id", None),
"created_by_name": getattr(created_by, "name", ""),
}
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_CREATED,
merchant_id=instance.merchant_id,
payload=payload,
)
def on_mission_replied(sender, instance, mission=None, responder=None, **kwargs):
mission = mission or instance.mission
payload = {
**_build_mission_payload(mission),
**_build_reply_payload(instance),
"responder_id": getattr(responder, "id", None),
"responder_name": getattr(responder, "name", ""),
}
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_REPLIED,
merchant_id=mission.merchant_id,
payload=payload,
)
def on_mission_completed(sender, instance, completed_by=None, reply=None, **kwargs):
payload = {
**_build_mission_payload(instance),
"completed_by_id": getattr(completed_by, "id", None),
"completed_by_name": getattr(completed_by, "name", ""),
}
if reply is not None:
payload.update(_build_reply_payload(reply))
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_COMPLETED,
merchant_id=instance.merchant_id,
payload=payload,
)
def on_mission_reply_rejected(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
mission = mission or instance.mission
payload = {
**_build_mission_payload(mission),
**_build_reply_payload(instance),
"rejected_by_id": getattr(rejected_by, "id", None),
"rejected_by_name": getattr(rejected_by, "name", ""),
"reason": reason or "",
}
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_REPLY_REJECTED,
merchant_id=mission.merchant_id,
payload=payload,
)
def on_mission_reopened(sender, instance, reopened_by=None, rejected_reply_ids=None, **kwargs):
payload = {
**_build_mission_payload(instance),
"reopened_by_id": getattr(reopened_by, "id", None),
"reopened_by_name": getattr(reopened_by, "name", ""),
"rejected_reply_ids": rejected_reply_ids or [],
"rejected_reply_ids_display": ", ".join(str(reply_id) for reply_id in (rejected_reply_ids or [])) or "",
}
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_REOPENED,
merchant_id=instance.merchant_id,
payload=payload,
)
def on_mission_cancelled(sender, instance, cancelled_by=None, **kwargs):
payload = {
**_build_mission_payload(instance),
"cancelled_by_id": getattr(cancelled_by, "id", None),
"cancelled_by_name": getattr(cancelled_by, "name", ""),
"cancelled_at": instance.cancelled_at.isoformat() if instance.cancelled_at else "",
}
_enqueue(
event_key=NotificationEventKeyEnum.MISSION_CANCELLED,
merchant_id=instance.merchant_id,
payload=payload,
)

View File

@@ -0,0 +1,86 @@
# Generated by Django 5.2.8 on 2026-04-10 02:39
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("basic_info", "0025_customer_uniq_customer_merchant_name"),
("contenttypes", "0002_remove_content_type_name"),
]
operations = [
migrations.CreateModel(
name="Mission",
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)),
("description", models.TextField(verbose_name="任务描述")),
("category", models.CharField(choices=[("general", "通用")], db_index=True, default="general", max_length=50, verbose_name="任务类型")),
("is_urgent", models.BooleanField(db_index=True, default=False, verbose_name="是否紧急")),
("is_completed", models.BooleanField(db_index=True, default=False, verbose_name="是否完成")),
("is_cancelled", models.BooleanField(db_index=True, default=False, verbose_name="是否取消")),
("cancelled_at", models.DateTimeField(blank=True, null=True, verbose_name="取消时间")),
("content_id", models.PositiveBigIntegerField(blank=True, db_index=True, null=True, verbose_name="关联对象ID")),
("cancelled_by", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="cancelled_missions", to="basic_info.employee", verbose_name="取消人")),
("content_type", models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="missions", to="contenttypes.contenttype", verbose_name="关联对象类型")),
("creator", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="created_missions", to="basic_info.employee", verbose_name="任务创建者")),
],
options={
"verbose_name": "任务",
"verbose_name_plural": "任务",
},
),
migrations.CreateModel(
name="MissionParticipant",
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)),
("employee", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="mission_participations", to="basic_info.employee", verbose_name="参与者")),
("mission", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="participants", to="mission.mission", verbose_name="任务")),
],
options={
"verbose_name": "任务参与者",
"verbose_name_plural": "任务参与者",
},
),
migrations.CreateModel(
name="MissionReply",
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)),
("content", models.TextField(verbose_name="回应内容")),
("replied_at", models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name="回应时间")),
("ends_task", models.BooleanField(db_index=True, default=False, verbose_name="是否结束任务")),
("is_rejected", models.BooleanField(db_index=True, default=False, verbose_name="是否被撤销")),
("rejected_at", models.DateTimeField(blank=True, null=True, verbose_name="撤销时间")),
("mission", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="replies", to="mission.mission", verbose_name="任务")),
("responder", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="mission_replies", to="basic_info.employee", verbose_name="回应者")),
],
options={
"verbose_name": "任务回应",
"verbose_name_plural": "任务回应",
"ordering": ["replied_at", "id"],
},
),
migrations.AddIndex(
model_name="mission",
index=models.Index(fields=["content_type", "content_id"], name="mission_mis_content_be6081_idx"),
),
migrations.AddIndex(
model_name="mission",
index=models.Index(fields=["is_completed", "is_cancelled"], name="mission_mis_is_comp_299039_idx"),
),
migrations.AddConstraint(
model_name="missionparticipant",
constraint=models.UniqueConstraint(fields=("mission", "employee"), name="unique_mission_participant"),
),
]

View File

@@ -0,0 +1,125 @@
# Generated by Django 5.2.8 on 2026-04-10 03:36
import django.db.models.deletion
from django.db import migrations, models
def populate_merchant_fields(apps, schema_editor):
Mission = apps.get_model("mission", "Mission")
MissionParticipant = apps.get_model("mission", "MissionParticipant")
MissionReply = apps.get_model("mission", "MissionReply")
for mission in Mission.objects.select_related("creator").filter(merchant__isnull=True):
mission.merchant_id = mission.creator.merchant_id
mission.save(update_fields=["merchant"])
for participant in MissionParticipant.objects.select_related("mission").filter(merchant__isnull=True):
participant.merchant_id = participant.mission.merchant_id
participant.save(update_fields=["merchant"])
for reply in MissionReply.objects.select_related("mission").filter(merchant__isnull=True):
reply.merchant_id = reply.mission.merchant_id
reply.save(update_fields=["merchant"])
class Migration(migrations.Migration):
dependencies = [
("basic_info", "0025_customer_uniq_customer_merchant_name"),
("mission", "0001_initial"),
]
operations = [
migrations.RemoveIndex(
model_name="mission",
name="mission_mis_content_be6081_idx",
),
migrations.RemoveIndex(
model_name="mission",
name="mission_mis_is_comp_299039_idx",
),
migrations.AddField(
model_name="mission",
name="merchant",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="missions",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AddField(
model_name="missionparticipant",
name="merchant",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="mission_participants",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AddField(
model_name="missionreply",
name="merchant",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="mission_replies",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AddField(
model_name="missionreply",
name="rejected_by",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="rejected_mission_replies",
to="basic_info.employee",
verbose_name="撤销人",
),
),
migrations.RunPython(populate_merchant_fields, migrations.RunPython.noop),
migrations.AlterField(
model_name="mission",
name="merchant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="missions",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AlterField(
model_name="missionparticipant",
name="merchant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="mission_participants",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AlterField(
model_name="missionreply",
name="merchant",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="mission_replies",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
migrations.AddIndex(
model_name="mission",
index=models.Index(fields=["merchant", "content_type", "content_id"], name="mission_mis_merchan_af589f_idx"),
),
migrations.AddIndex(
model_name="mission",
index=models.Index(fields=["merchant", "is_completed", "is_cancelled"], name="mission_mis_merchan_6d6d37_idx"),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2026-04-10 04:40
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('mission', '0002_add_merchant_and_reply_rejected_by'),
]
operations = [
migrations.AlterModelOptions(
name='mission',
options={'permissions': [('reopen_mission', 'Can reopen mission'), ('reject_mission_reply', 'Can reject mission reply')], 'verbose_name': '任务', 'verbose_name_plural': '任务'},
),
]

View File

@@ -0,0 +1,101 @@
import django.db.models.deletion
from django.db import migrations, models
LEGACY_CATEGORY_NAME_MAP = {
"general": "通用",
}
def forwards(apps, schema_editor):
Mission = apps.get_model("mission", "Mission")
MissionCategory = apps.get_model("mission", "MissionCategory")
db_alias = schema_editor.connection.alias
category_cache = {}
for mission in Mission.objects.using(db_alias).all().only("id", "merchant_id", "category").iterator():
legacy_value = (mission.category or "").strip() or "general"
category_name = LEGACY_CATEGORY_NAME_MAP.get(legacy_value, legacy_value)
cache_key = (mission.merchant_id, category_name)
category = category_cache.get(cache_key)
if category is None:
category, _ = MissionCategory.objects.using(db_alias).get_or_create(
merchant_id=mission.merchant_id,
name=category_name,
)
category_cache[cache_key] = category
Mission.objects.using(db_alias).filter(id=mission.id).update(category_ref_id=category.id)
class Migration(migrations.Migration):
dependencies = [
("basic_info", "0025_customer_uniq_customer_merchant_name"),
("mission", "0003_alter_mission_options"),
]
operations = [
migrations.CreateModel(
name="MissionCategory",
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=50, verbose_name="分类名称")),
(
"merchant",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="mission_categories",
to="basic_info.merchant",
verbose_name="所属商户",
),
),
],
options={
"verbose_name": "任务分类",
"verbose_name_plural": "任务分类",
"ordering": ["id"],
"constraints": [
models.UniqueConstraint(
fields=("merchant", "name"),
name="unique_mission_category_name_per_merchant",
),
],
},
),
migrations.AddField(
model_name="mission",
name="category_ref",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="+",
to="mission.missioncategory",
verbose_name="任务类型",
),
),
migrations.RunPython(forwards, migrations.RunPython.noop),
migrations.RemoveField(
model_name="mission",
name="category",
),
migrations.RenameField(
model_name="mission",
old_name="category_ref",
new_name="category",
),
migrations.AlterField(
model_name="mission",
name="category",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="missions",
to="mission.missioncategory",
verbose_name="任务类型",
),
),
]

View File

189
mission/models.py Normal file
View File

@@ -0,0 +1,189 @@
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils import timezone
from flower.common import ModelBase
class MissionCategory(ModelBase):
"""任务分类字典,按商户隔离。"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="mission_categories",
verbose_name="所属商户",
)
name = models.CharField(max_length=50, verbose_name="分类名称")
def __str__(self):
return self.name
class Meta:
verbose_name = "任务分类"
verbose_name_plural = "任务分类"
ordering = ["id"]
constraints = [
models.UniqueConstraint(fields=["merchant", "name"], name="unique_mission_category_name_per_merchant"),
]
class Mission(ModelBase):
"""中立任务,可选关联到系统中的任意业务对象。"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="missions",
verbose_name="所属商户",
)
description = models.TextField(verbose_name="任务描述")
category = models.ForeignKey(
MissionCategory,
on_delete=models.PROTECT,
related_name="missions",
verbose_name="任务类型",
)
is_urgent = models.BooleanField(default=False, db_index=True, verbose_name="是否紧急")
is_completed = models.BooleanField(default=False, db_index=True, verbose_name="是否完成")
is_cancelled = models.BooleanField(default=False, db_index=True, verbose_name="是否取消")
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name="取消时间")
creator = models.ForeignKey(
"basic_info.Employee",
on_delete=models.PROTECT,
related_name="created_missions",
verbose_name="任务创建者",
)
cancelled_by = models.ForeignKey(
"basic_info.Employee",
on_delete=models.PROTECT,
related_name="cancelled_missions",
null=True,
blank=True,
verbose_name="取消人",
)
content_type = models.ForeignKey(
ContentType,
on_delete=models.SET_NULL,
related_name="missions",
null=True,
blank=True,
verbose_name="关联对象类型",
)
content_id = models.PositiveBigIntegerField(null=True, blank=True, db_index=True, verbose_name="关联对象ID")
content_object = GenericForeignKey("content_type", "content_id")
@property
def has_ending_reply(self) -> bool:
return self.replies.filter(ends_task=True, is_rejected=False).exists()
@property
def can_reply(self) -> bool:
return not self.is_cancelled and not self.has_ending_reply
def get_participants(self):
return self.participants.select_related("employee")
def filter_participants(self, **filters):
return self.get_participants().filter(**filters)
@property
def category_name(self) -> str:
return self.category.name
def __str__(self):
return f"Mission #{self.id}"
class Meta:
verbose_name = "任务"
verbose_name_plural = "任务"
permissions = [
("reopen_mission", "Can reopen mission"),
("reject_mission_reply", "Can reject mission reply"),
]
indexes = [
models.Index(fields=["merchant", "content_type", "content_id"]),
models.Index(fields=["merchant", "is_completed", "is_cancelled"]),
]
class MissionParticipant(ModelBase):
"""任务参与员工。"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="mission_participants",
verbose_name="所属商户",
)
mission = models.ForeignKey(
Mission,
on_delete=models.CASCADE,
related_name="participants",
verbose_name="任务",
)
employee = models.ForeignKey(
"basic_info.Employee",
on_delete=models.PROTECT,
related_name="mission_participations",
verbose_name="参与者",
)
def __str__(self):
return f"{self.mission_id} - {self.employee_id}"
class Meta:
verbose_name = "任务参与者"
verbose_name_plural = "任务参与者"
constraints = [
models.UniqueConstraint(fields=["mission", "employee"], name="unique_mission_participant"),
]
class MissionReply(ModelBase):
"""任务回应记录。"""
id = models.BigAutoField(primary_key=True)
merchant = models.ForeignKey(
"basic_info.Merchant",
on_delete=models.PROTECT,
related_name="mission_replies",
verbose_name="所属商户",
)
mission = models.ForeignKey(
Mission,
on_delete=models.CASCADE,
related_name="replies",
verbose_name="任务",
)
responder = models.ForeignKey(
"basic_info.Employee",
on_delete=models.PROTECT,
related_name="mission_replies",
verbose_name="回应者",
)
content = models.TextField(verbose_name="回应内容")
replied_at = models.DateTimeField(default=timezone.now, db_index=True, verbose_name="回应时间")
ends_task = models.BooleanField(default=False, db_index=True, verbose_name="是否结束任务")
is_rejected = models.BooleanField(default=False, db_index=True, verbose_name="是否被撤销")
rejected_by = models.ForeignKey(
"basic_info.Employee",
on_delete=models.PROTECT,
related_name="rejected_mission_replies",
null=True,
blank=True,
verbose_name="撤销人",
)
rejected_at = models.DateTimeField(null=True, blank=True, verbose_name="撤销时间")
def __str__(self):
return f"MissionReply #{self.id}"
class Meta:
verbose_name = "任务回应"
verbose_name_plural = "任务回应"
ordering = ["replied_at", "id"]

308
mission/services.py Normal file
View File

@@ -0,0 +1,308 @@
import logging
from django.db import transaction
from django.utils import timezone
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.signals import (
mission_cancelled,
mission_completed,
mission_created,
mission_reopened,
mission_replied,
mission_reply_rejected,
)
logger = logging.getLogger(__name__)
DEFAULT_MISSION_CATEGORY_NAME = "通用"
def _send_signal_on_commit(signal, *, sender, **payload) -> None:
def _send():
try:
signal.send(sender=sender, **payload)
except Exception:
logger.exception("[mission.services] 触发 mission signal 失败(已忽略)")
transaction.on_commit(_send)
def _assert_employee_belongs_to_mission(employee, mission: Mission, role: str) -> None:
if employee is None:
raise ValueError(f"{role}不能为空")
if employee.merchant_id != mission.merchant_id:
raise ValueError(f"{role}不属于任务所属商户")
def _get_default_mission_category(*, merchant) -> MissionCategory:
category, _ = MissionCategory.objects.get_or_create(
merchant=merchant,
name=DEFAULT_MISSION_CATEGORY_NAME,
)
return category
def _assert_category_belongs_to_merchant(category: MissionCategory | None, *, merchant) -> None:
if category is None:
return
if category.merchant_id != merchant.id:
raise ValueError("任务分类不属于当前商户")
def _validate_content_object_merchant(*, content_type, content_id, merchant) -> None:
if content_type is None and content_id is None:
return
if content_type is None or content_id is None:
raise ValueError("content_type 与 content_id 必须同时提供或同时为空")
try:
content_object = content_type.get_object_for_this_type(pk=content_id)
except Exception as exc:
raise ValueError("关联业务对象不存在") from exc
content_object_merchant_id = getattr(content_object, "merchant_id", None)
if content_object_merchant_id is not None and content_object_merchant_id != merchant.id:
raise ValueError("关联业务对象不属于当前商户")
@transaction.atomic
def set_mission_participants(*, mission: Mission, participant_ids: list[int]) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
participant_ids = list(dict.fromkeys(participant_ids or []))
from basic_info.models import Employee
employees = list(Employee.objects.filter(id__in=participant_ids, merchant=mission.merchant))
if len(employees) != len(participant_ids):
raise ValueError("参与者不存在或不属于任务所属商户")
MissionParticipant.objects.filter(mission=mission).exclude(employee_id__in=participant_ids).delete()
existing_employee_ids = set(
MissionParticipant.objects.filter(mission=mission, employee_id__in=participant_ids)
.values_list("employee_id", flat=True)
)
MissionParticipant.objects.bulk_create(
[
MissionParticipant(merchant=mission.merchant, mission=mission, employee=employee)
for employee in employees
if employee.id not in existing_employee_ids
]
)
return mission
@transaction.atomic
def create_mission(
*,
creator,
description: str,
category: MissionCategory | None = None,
content_type=None,
content_id: int | None = None,
participant_ids: list[int] | None = None,
) -> Mission:
if creator is None:
raise ValueError("任务创建者不能为空")
merchant = creator.merchant
_validate_content_object_merchant(content_type=content_type, content_id=content_id, merchant=merchant)
_assert_category_belongs_to_merchant(category, merchant=merchant)
mission = Mission.objects.create(
merchant=merchant,
creator=creator,
description=description,
category=category or _get_default_mission_category(merchant=merchant),
content_type=content_type,
content_id=content_id,
)
if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids)
_send_signal_on_commit(
mission_created,
sender=Mission,
instance=mission,
created_by=creator,
)
return mission
@transaction.atomic
def update_mission(
*,
mission: Mission,
updated_by,
description: str | None = None,
category: MissionCategory | None = None,
content_type=None,
content_id: int | None = None,
update_content_object: bool = False,
participant_ids: list[int] | None = None,
) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务的员工")
update_fields = []
if description is not None:
mission.description = description
update_fields.append("description")
if category is not None:
_assert_category_belongs_to_merchant(category, merchant=mission.merchant)
mission.category = category
update_fields.append("category")
if update_content_object:
_validate_content_object_merchant(
content_type=content_type,
content_id=content_id,
merchant=mission.merchant,
)
mission.content_type = content_type
mission.content_id = content_id
update_fields.extend(["content_type", "content_id"])
if update_fields:
mission.save(update_fields=[*update_fields, "updated_at"])
if participant_ids is not None:
set_mission_participants(mission=mission, participant_ids=participant_ids)
return mission
@transaction.atomic
def create_mission_reply(
*,
mission: Mission,
responder,
content: str,
ends_task: bool = False,
) -> MissionReply:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(responder, mission, "回应者")
if not mission.can_reply:
raise ValueError("任务当前不允许继续回应")
reply = MissionReply.objects.create(
merchant=mission.merchant,
mission=mission,
responder=responder,
content=content,
ends_task=ends_task,
)
if ends_task and not mission.is_completed:
mission.is_completed = True
mission.save(update_fields=["is_completed", "updated_at"])
_send_signal_on_commit(
mission_completed,
sender=Mission,
instance=mission,
completed_by=responder,
reply=reply,
)
_send_signal_on_commit(
mission_replied,
sender=MissionReply,
instance=reply,
mission=mission,
responder=responder,
)
return reply
@transaction.atomic
def reopen_mission(*, mission: Mission, reopened_by) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(reopened_by, mission, "重新打开任务的员工")
ending_replies = mission.replies.select_for_update().filter(ends_task=True, is_rejected=False)
if mission.is_cancelled:
raise ValueError("已取消任务不能重新打开")
if not mission.is_completed:
raise ValueError("未完成任务不能重新打开")
if not ending_replies.exists():
raise ValueError("任务没有可撤销的结束回应")
rejected_reply_ids = list(ending_replies.values_list("id", flat=True))
now = timezone.now()
ending_replies.update(ends_task=False, is_rejected=True, rejected_by=reopened_by, rejected_at=now)
mission.is_completed = False
mission.save(update_fields=["is_completed", "updated_at"])
for reply in MissionReply.objects.filter(id__in=rejected_reply_ids):
_send_signal_on_commit(
mission_reply_rejected,
sender=MissionReply,
instance=reply,
mission=mission,
rejected_by=reopened_by,
reason="reopen",
)
_send_signal_on_commit(
mission_reopened,
sender=Mission,
instance=mission,
reopened_by=reopened_by,
rejected_reply_ids=rejected_reply_ids,
)
return mission
@transaction.atomic
def reject_reply(*, reply: MissionReply, rejected_by) -> MissionReply:
reply = MissionReply.objects.select_for_update().select_related("mission").get(pk=reply.pk)
mission = Mission.objects.select_for_update().get(pk=reply.mission_id)
_assert_employee_belongs_to_mission(rejected_by, mission, "撤销回应的员工")
if mission.is_cancelled:
raise ValueError("已取消任务不能撤销回应")
if reply.is_rejected:
raise ValueError("回应已被撤销")
was_ending_reply = reply.ends_task
now = timezone.now()
reply.ends_task = False
reply.is_rejected = True
reply.rejected_by = rejected_by
reply.rejected_at = now
reply.save(update_fields=["ends_task", "is_rejected", "rejected_by", "rejected_at", "updated_at"])
has_other_ending_reply = mission.replies.exclude(pk=reply.pk).filter(ends_task=True, is_rejected=False).exists()
if was_ending_reply and not has_other_ending_reply and mission.is_completed:
mission.is_completed = False
mission.save(update_fields=["is_completed", "updated_at"])
_send_signal_on_commit(
mission_reply_rejected,
sender=MissionReply,
instance=reply,
mission=mission,
rejected_by=rejected_by,
reason="reject_reply",
)
return reply
@transaction.atomic
def cancel_mission(*, mission: Mission, cancelled_by) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(cancelled_by, mission, "取消任务的员工")
if mission.is_cancelled:
raise ValueError("任务已取消")
mission.is_cancelled = True
mission.cancelled_by = cancelled_by
mission.cancelled_at = timezone.now()
mission.save(update_fields=["is_cancelled", "cancelled_by", "cancelled_at", "updated_at"])
_send_signal_on_commit(
mission_cancelled,
sender=Mission,
instance=mission,
cancelled_by=cancelled_by,
)
return mission
@transaction.atomic
def set_mission_urgent(*, mission: Mission, updated_by, is_urgent: bool) -> Mission:
mission = Mission.objects.select_for_update().get(pk=mission.pk)
_assert_employee_belongs_to_mission(updated_by, mission, "更新任务紧急状态的员工")
mission.is_urgent = is_urgent
mission.save(update_fields=["is_urgent", "updated_at"])
return mission

45
mission/signals.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Mission domain signals.
Signals are emitted by the mission service layer after successful business
state transitions. Use transaction.on_commit at send sites so handlers only run
after the database transaction is committed.
"""
from django.dispatch import Signal
# Payload:
# - instance: Mission
# - created_by: Employee
mission_created = Signal()
# Payload:
# - instance: MissionReply
# - mission: Mission
# - responder: Employee
mission_replied = Signal()
# Payload:
# - instance: Mission
# - completed_by: Employee
# - reply: MissionReply
mission_completed = Signal()
# Payload:
# - instance: MissionReply
# - mission: Mission
# - rejected_by: Employee
# - reason: "reject_reply" | "reopen"
mission_reply_rejected = Signal()
# Payload:
# - instance: Mission
# - reopened_by: Employee
# - rejected_reply_ids: list[int]
mission_reopened = Signal()
# Payload:
# - instance: Mission
# - cancelled_by: Employee
mission_cancelled = Signal()

528
mission/tests.py Normal file
View File

@@ -0,0 +1,528 @@
from django.test import TestCase
from django.contrib.contenttypes.models import ContentType
from unittest.mock import patch
from basic_info.models import Employee, Merchant, MerchantTypeEnum
from mission.models import Mission, MissionCategory, MissionParticipant, MissionReply
from mission.services import (
cancel_mission,
create_mission,
create_mission_reply,
reject_reply,
reopen_mission,
set_mission_participants,
set_mission_urgent,
update_mission,
)
from mission.signals import (
mission_cancelled,
mission_completed,
mission_created,
mission_reopened,
mission_replied,
mission_reply_rejected,
)
class MissionModelTestCase(TestCase):
def setUp(self):
self.merchant = Merchant.objects.create(name="测试商户", type=MerchantTypeEnum.STORE)
self.other_merchant = Merchant.objects.create(name="其他商户", type=MerchantTypeEnum.STORE)
self.default_category = MissionCategory.objects.create(merchant=self.merchant, name="通用")
self.custom_category = MissionCategory.objects.create(merchant=self.merchant, name="售后")
self.other_category = MissionCategory.objects.create(merchant=self.other_merchant, name="通用")
self.creator = Employee.objects.create(merchant=self.merchant, name="创建者")
self.responder = Employee.objects.create(merchant=self.merchant, name="回应者")
self.other_employee = Employee.objects.create(merchant=self.other_merchant, name="外部员工")
self.mission = Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
description="测试任务",
creator=self.creator,
)
def test_participant_helpers(self):
MissionParticipant.objects.create(
merchant=self.merchant,
mission=self.mission,
employee=self.responder,
)
self.assertEqual(self.mission.get_participants().count(), 1)
self.assertEqual(
self.mission.filter_participants(employee=self.responder).first().employee,
self.responder,
)
def test_create_ending_reply_marks_mission_completed(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成任务",
ends_task=True,
)
self.mission.refresh_from_db()
reply.refresh_from_db()
self.assertTrue(reply.ends_task)
self.assertEqual(reply.merchant, self.merchant)
self.assertTrue(self.mission.is_completed)
self.assertTrue(self.mission.has_ending_reply)
self.assertFalse(self.mission.can_reply)
def test_reopen_mission_rejects_ending_reply(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成任务",
ends_task=True,
)
reopen_mission(mission=self.mission, reopened_by=self.creator)
self.mission.refresh_from_db()
reply.refresh_from_db()
self.assertFalse(self.mission.is_completed)
self.assertFalse(reply.ends_task)
self.assertTrue(reply.is_rejected)
self.assertEqual(reply.rejected_by, self.creator)
self.assertIsNotNone(reply.rejected_at)
self.assertTrue(self.mission.can_reply)
def test_reopen_cancelled_mission_is_rejected(self):
create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成任务",
ends_task=True,
)
self.mission.is_cancelled = True
self.mission.save(update_fields=["is_cancelled", "updated_at"])
with self.assertRaises(ValueError):
reopen_mission(mission=self.mission, reopened_by=self.creator)
def test_cross_merchant_responder_is_rejected(self):
with self.assertRaises(ValueError):
create_mission_reply(
mission=self.mission,
responder=self.other_employee,
content="跨商户回应",
)
def test_create_reply_rejects_empty_responder(self):
with self.assertRaises(ValueError):
create_mission_reply(
mission=self.mission,
responder=None,
content="无回应者",
)
def test_reject_reply_marks_reply_rejected_and_reopens_mission(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成任务",
ends_task=True,
)
reject_reply(reply=reply, rejected_by=self.creator)
self.mission.refresh_from_db()
reply.refresh_from_db()
self.assertFalse(self.mission.is_completed)
self.assertFalse(reply.ends_task)
self.assertTrue(reply.is_rejected)
self.assertEqual(reply.rejected_by, self.creator)
self.assertIsNotNone(reply.rejected_at)
def test_cancel_mission_records_employee_and_time(self):
cancel_mission(mission=self.mission, cancelled_by=self.creator)
self.mission.refresh_from_db()
self.assertTrue(self.mission.is_cancelled)
self.assertEqual(self.mission.cancelled_by, self.creator)
self.assertIsNotNone(self.mission.cancelled_at)
def test_cross_merchant_cancel_is_rejected(self):
with self.assertRaises(ValueError):
cancel_mission(mission=self.mission, cancelled_by=self.other_employee)
def test_create_mission_sets_defaults_and_participants(self):
mission = create_mission(
creator=self.creator,
description="service 创建任务",
participant_ids=[self.responder.id, self.responder.id],
)
self.assertEqual(mission.merchant, self.merchant)
self.assertEqual(mission.category, self.default_category)
self.assertFalse(mission.is_urgent)
self.assertEqual(list(mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
def test_create_mission_rejects_empty_creator(self):
with self.assertRaises(ValueError):
create_mission(creator=None, description="无创建人")
def test_create_mission_rejects_partial_content_object(self):
with self.assertRaises(ValueError):
create_mission(
creator=self.creator,
description="缺少 content_id",
content_type=ContentType.objects.get_for_model(Mission),
)
def test_create_mission_rejects_missing_content_object(self):
with self.assertRaises(ValueError):
create_mission(
creator=self.creator,
description="不存在的关联对象",
content_type=ContentType.objects.get_for_model(Mission),
content_id=999999,
)
def test_create_mission_rejects_cross_merchant_content_object(self):
other_mission = Mission.objects.create(
merchant=self.other_merchant,
category=self.other_category,
creator=self.other_employee,
description="其他商户任务",
)
with self.assertRaises(ValueError):
create_mission(
creator=self.creator,
description="跨商户关联对象",
content_type=ContentType.objects.get_for_model(Mission),
content_id=other_mission.id,
)
def test_update_mission_updates_content_object_and_participants(self):
related = Mission.objects.create(
merchant=self.merchant,
category=self.default_category,
creator=self.creator,
description="同商户关联对象",
)
update_mission(
mission=self.mission,
updated_by=self.creator,
description="更新描述",
category=self.custom_category,
content_type=ContentType.objects.get_for_model(Mission),
content_id=related.id,
update_content_object=True,
participant_ids=[self.responder.id],
)
self.mission.refresh_from_db()
self.assertEqual(self.mission.description, "更新描述")
self.assertEqual(self.mission.category, self.custom_category)
self.assertEqual(self.mission.content_id, related.id)
self.assertEqual(list(self.mission.participants.values_list("employee_id", flat=True)), [self.responder.id])
def test_create_mission_rejects_cross_merchant_category(self):
with self.assertRaises(ValueError):
create_mission(
creator=self.creator,
description="跨商户分类",
category=self.other_category,
)
def test_update_mission_rejects_cross_merchant_employee(self):
with self.assertRaises(ValueError):
update_mission(
mission=self.mission,
updated_by=self.other_employee,
description="非法更新",
)
def test_set_participants_rejects_cross_merchant_employee(self):
with self.assertRaises(ValueError):
set_mission_participants(
mission=self.mission,
participant_ids=[self.other_employee.id],
)
def test_update_mission_rejects_cross_merchant_category(self):
with self.assertRaises(ValueError):
update_mission(
mission=self.mission,
updated_by=self.creator,
category=self.other_category,
)
def test_create_reply_rejects_after_ending_reply(self):
create_mission_reply(
mission=self.mission,
responder=self.responder,
content="结束任务",
ends_task=True,
)
with self.assertRaises(ValueError):
create_mission_reply(
mission=self.mission,
responder=self.responder,
content="后续回应",
)
def test_reopen_uncompleted_mission_is_rejected(self):
with self.assertRaises(ValueError):
reopen_mission(mission=self.mission, reopened_by=self.creator)
def test_reopen_completed_mission_without_ending_reply_is_rejected(self):
self.mission.is_completed = True
self.mission.save(update_fields=["is_completed", "updated_at"])
with self.assertRaises(ValueError):
reopen_mission(mission=self.mission, reopened_by=self.creator)
def test_reopen_cross_merchant_employee_is_rejected(self):
create_mission_reply(
mission=self.mission,
responder=self.responder,
content="结束任务",
ends_task=True,
)
with self.assertRaises(ValueError):
reopen_mission(mission=self.mission, reopened_by=self.other_employee)
def test_reject_reply_rejects_cancelled_mission(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="普通回应",
)
self.mission.is_cancelled = True
self.mission.save(update_fields=["is_cancelled", "updated_at"])
with self.assertRaises(ValueError):
reject_reply(reply=reply, rejected_by=self.creator)
def test_reject_reply_rejects_already_rejected_reply(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="普通回应",
)
reject_reply(reply=reply, rejected_by=self.creator)
with self.assertRaises(ValueError):
reject_reply(reply=reply, rejected_by=self.creator)
def test_reject_reply_rejects_cross_merchant_employee(self):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="普通回应",
)
with self.assertRaises(ValueError):
reject_reply(reply=reply, rejected_by=self.other_employee)
def test_reject_non_ending_reply_keeps_completed_state(self):
self.mission.is_completed = True
self.mission.save(update_fields=["is_completed", "updated_at"])
reply = MissionReply.objects.create(
merchant=self.merchant,
mission=self.mission,
responder=self.responder,
content="普通回应",
ends_task=False,
)
reject_reply(reply=reply, rejected_by=self.creator)
self.mission.refresh_from_db()
self.assertTrue(self.mission.is_completed)
def test_cancel_already_cancelled_mission_is_rejected(self):
cancel_mission(mission=self.mission, cancelled_by=self.creator)
with self.assertRaises(ValueError):
cancel_mission(mission=self.mission, cancelled_by=self.creator)
def test_set_urgent_updates_status_and_rejects_cross_merchant_employee(self):
set_mission_urgent(mission=self.mission, updated_by=self.creator, is_urgent=True)
self.mission.refresh_from_db()
self.assertTrue(self.mission.is_urgent)
with self.assertRaises(ValueError):
set_mission_urgent(mission=self.mission, updated_by=self.other_employee, is_urgent=False)
def test_create_mission_emits_mission_created_signal(self):
received = []
def receiver(sender, instance, created_by=None, **kwargs):
received.append((sender, instance.id, created_by.id))
mission_created.connect(receiver, sender=Mission, dispatch_uid="test_mission_created")
try:
with self.captureOnCommitCallbacks(execute=True):
mission = create_mission(creator=self.creator, description="触发创建信号")
finally:
mission_created.disconnect(sender=Mission, dispatch_uid="test_mission_created")
self.assertEqual(received, [(Mission, mission.id, self.creator.id)])
def test_create_reply_emits_replied_and_completed_signals(self):
received = []
def on_replied(sender, instance, mission=None, responder=None, **kwargs):
received.append(("replied", sender, instance.id, mission.id, responder.id))
def on_completed(sender, instance, completed_by=None, reply=None, **kwargs):
received.append(("completed", sender, instance.id, completed_by.id, reply.id))
mission_replied.connect(on_replied, sender=MissionReply, dispatch_uid="test_mission_replied")
mission_completed.connect(on_completed, sender=Mission, dispatch_uid="test_mission_completed")
try:
with self.captureOnCommitCallbacks(execute=True):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成并触发信号",
ends_task=True,
)
finally:
mission_replied.disconnect(sender=MissionReply, dispatch_uid="test_mission_replied")
mission_completed.disconnect(sender=Mission, dispatch_uid="test_mission_completed")
self.assertIn(("completed", Mission, self.mission.id, self.responder.id, reply.id), received)
self.assertIn(("replied", MissionReply, reply.id, self.mission.id, self.responder.id), received)
def test_reject_reply_emits_reply_rejected_signal(self):
received = []
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="待撤销回应",
)
def receiver(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
received.append((sender, instance.id, mission.id, rejected_by.id, reason))
mission_reply_rejected.connect(receiver, sender=MissionReply, dispatch_uid="test_reply_rejected")
try:
with self.captureOnCommitCallbacks(execute=True):
reject_reply(reply=reply, rejected_by=self.creator)
finally:
mission_reply_rejected.disconnect(sender=MissionReply, dispatch_uid="test_reply_rejected")
self.assertEqual(received, [(MissionReply, reply.id, self.mission.id, self.creator.id, "reject_reply")])
def test_reopen_emits_reopened_and_reply_rejected_signals(self):
received = []
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="结束并等待 reopen",
ends_task=True,
)
def on_reply_rejected(sender, instance, mission=None, rejected_by=None, reason=None, **kwargs):
received.append(("reply_rejected", sender, instance.id, mission.id, rejected_by.id, reason))
def on_reopened(sender, instance, reopened_by=None, rejected_reply_ids=None, **kwargs):
received.append(("reopened", sender, instance.id, reopened_by.id, rejected_reply_ids))
mission_reply_rejected.connect(on_reply_rejected, sender=MissionReply, dispatch_uid="test_reopen_reply_rejected")
mission_reopened.connect(on_reopened, sender=Mission, dispatch_uid="test_mission_reopened")
try:
with self.captureOnCommitCallbacks(execute=True):
reopen_mission(mission=self.mission, reopened_by=self.creator)
finally:
mission_reply_rejected.disconnect(sender=MissionReply, dispatch_uid="test_reopen_reply_rejected")
mission_reopened.disconnect(sender=Mission, dispatch_uid="test_mission_reopened")
self.assertIn(("reply_rejected", MissionReply, reply.id, self.mission.id, self.creator.id, "reopen"), received)
self.assertIn(("reopened", Mission, self.mission.id, self.creator.id, [reply.id]), received)
def test_cancel_mission_emits_cancelled_signal(self):
received = []
def receiver(sender, instance, cancelled_by=None, **kwargs):
received.append((sender, instance.id, cancelled_by.id))
mission_cancelled.connect(receiver, sender=Mission, dispatch_uid="test_mission_cancelled")
try:
with self.captureOnCommitCallbacks(execute=True):
cancel_mission(mission=self.mission, cancelled_by=self.creator)
finally:
mission_cancelled.disconnect(sender=Mission, dispatch_uid="test_mission_cancelled")
self.assertEqual(received, [(Mission, self.mission.id, self.creator.id)])
@patch("mission.handlers.enqueue_notification_event")
def test_mission_created_handler_enqueues_notifier_event(self, mock_enqueue):
with self.captureOnCommitCallbacks(execute=True):
mission = create_mission(creator=self.creator, description="触发任务创建通知")
mock_enqueue.assert_called_once()
self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.created")
self.assertEqual(mock_enqueue.call_args.kwargs["merchant_id"], self.merchant.id)
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], mission.id)
@patch("mission.handlers.enqueue_notification_event")
def test_create_ending_reply_handler_enqueues_replied_and_completed_notifications(self, mock_enqueue):
with self.captureOnCommitCallbacks(execute=True):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="完成任务并通知",
ends_task=True,
)
self.assertEqual(mock_enqueue.call_count, 2)
event_keys = {call.kwargs["event_key"] for call in mock_enqueue.call_args_list}
self.assertEqual(event_keys, {"mission.replied", "mission.completed"})
payloads = [call.kwargs["payload"] for call in mock_enqueue.call_args_list]
self.assertTrue(any(payload.get("reply_id") == reply.id for payload in payloads))
@patch("mission.handlers.enqueue_notification_event")
def test_reject_reply_handler_enqueues_notification(self, mock_enqueue):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="待撤销回应",
)
with self.captureOnCommitCallbacks(execute=True):
reject_reply(reply=reply, rejected_by=self.creator)
mock_enqueue.assert_called_once()
self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.reply_rejected")
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["reply_id"], reply.id)
@patch("mission.handlers.enqueue_notification_event")
def test_reopen_mission_handler_enqueues_rejected_and_reopened_notifications(self, mock_enqueue):
reply = create_mission_reply(
mission=self.mission,
responder=self.responder,
content="结束任务",
ends_task=True,
)
with self.captureOnCommitCallbacks(execute=True):
reopen_mission(mission=self.mission, reopened_by=self.creator)
self.assertEqual(mock_enqueue.call_count, 2)
event_keys = {call.kwargs["event_key"] for call in mock_enqueue.call_args_list}
self.assertEqual(event_keys, {"mission.reply_rejected", "mission.reopened"})
self.assertTrue(
any(reply.id in call.kwargs["payload"].get("rejected_reply_ids", []) for call in mock_enqueue.call_args_list)
)
@patch("mission.handlers.enqueue_notification_event")
def test_cancel_mission_handler_enqueues_notification(self, mock_enqueue):
with self.captureOnCommitCallbacks(execute=True):
cancel_mission(mission=self.mission, cancelled_by=self.creator)
mock_enqueue.assert_called_once()
self.assertEqual(mock_enqueue.call_args.kwargs["event_key"], "mission.cancelled")
self.assertEqual(mock_enqueue.call_args.kwargs["payload"]["mission_id"], self.mission.id)

3
mission/views.py Normal file
View File

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

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.