import logging from typing import Any from celery import shared_task from django.db import transaction from django.db.models import Exists, F, OuterRef from django.utils import timezone from mission import models as mission_models from mission.payload_processors import apply_mission_payload_processor from mission import services as mission_services from notifier.models import NotificationEventKeyEnum from notifier.services import dispatch_notification_event logger = logging.getLogger(__name__) def _build_unreplied_mission_payload(*, mission: mission_models.Mission, notified_at) -> dict[str, Any]: participant_names = list( mission.get_participants().values_list("employee__name", flat=True) ) content_type = getattr(mission.content_type, "model", None) next_count = mission.unreplied_notify_sent_count + 1 payload = { "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 "", "payload_processor": getattr(mission.category, "payload_processor", "") or "", "unreplied_notify_interval_minutes": mission.unreplied_notify_interval_minutes, "unreplied_notify_max_count": mission.unreplied_notify_max_count, "unreplied_notify_sent_count": next_count, "unreplied_last_notified_at": notified_at.isoformat() if notified_at else "", } return apply_mission_payload_processor( mission=mission, event_key=NotificationEventKeyEnum.MISSION_UNREPLIED, payload=payload, ) def _get_due_unreplied_mission_ids(*, limit: int) -> list[int]: effective_reply_exists = mission_models.MissionReply.objects.filter( mission_id=OuterRef("pk"), is_rejected=False, ) queryset = ( mission_models.Mission.objects.filter( notify_if_unreplied=True, is_completed=False, is_cancelled=False, unreplied_notify_interval_minutes__gt=0, ) .filter(unreplied_notify_sent_count__lt=F("unreplied_notify_max_count")) .annotate(has_effective_reply=Exists(effective_reply_exists)) .filter(has_effective_reply=False) .order_by("id") ) return list(queryset.values_list("id", flat=True)[:limit]) def _dispatch_unreplied_notification_for_mission(*, mission_id: int) -> dict[str, Any]: with transaction.atomic(): mission = ( mission_models.Mission.objects.select_for_update(skip_locked=True) .select_related("category", "creator") .prefetch_related("participants__employee") .filter(id=mission_id) .first() ) if mission is None: return {"mission_id": mission_id, "status": "missing"} if not mission.notify_if_unreplied: return {"mission_id": mission.id, "status": "disabled"} if mission.is_completed or mission.is_cancelled: return {"mission_id": mission.id, "status": "inactive"} if mission.unreplied_notify_interval_minutes is None or mission.unreplied_notify_interval_minutes <= 0: return {"mission_id": mission.id, "status": "invalid-interval"} if mission.unreplied_notify_sent_count >= mission.unreplied_notify_max_count: return {"mission_id": mission.id, "status": "maxed"} if mission.replies.filter(is_rejected=False).exists(): return {"mission_id": mission.id, "status": "has-reply"} now = timezone.now() due_at = mission_services.get_mission_unreplied_due_at(mission=mission) if due_at is None or now < due_at: return { "mission_id": mission.id, "status": "not-due", "due_at": due_at.isoformat() if due_at else None, } results = dispatch_notification_event( event_key=NotificationEventKeyEnum.MISSION_UNREPLIED, merchant_id=mission.merchant_id, payload=_build_unreplied_mission_payload(mission=mission, notified_at=now), ) if not results: return {"mission_id": mission.id, "status": "no-route"} mission.unreplied_last_notified_at = now mission.unreplied_notify_sent_count += 1 mission.save( update_fields=[ "unreplied_last_notified_at", "unreplied_notify_sent_count", "updated_at", ] ) return { "mission_id": mission.id, "status": "sent", "results": results, "sent_count": mission.unreplied_notify_sent_count, } @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 @shared_task(bind=True) def notify_unreplied_missions_task(self, *, limit: int = 100) -> dict: mission_ids = _get_due_unreplied_mission_ids(limit=max(1, int(limit))) items = [ _dispatch_unreplied_notification_for_mission(mission_id=mission_id) for mission_id in mission_ids ] summary = { "task_id": self.request.id, "candidate_count": len(mission_ids), "processed_count": len(items), "sent_count": sum(1 for item in items if item.get("status") == "sent"), "items": items, } logger.info("[notifier.tasks] unreplied mission notify task finished: %s", summary) return summary