From 1a1b3ee9d4191b4a4d1b398acc12e07ee632828c Mon Sep 17 00:00:00 2001 From: bruce Date: Sun, 7 Jun 2026 22:13:56 +0800 Subject: [PATCH] feat: show feishu notification status --- review_agent/application_form_fill/views.py | 4 ++ review_agent/file_summary/views.py | 4 ++ review_agent/notifications/presenter.py | 42 ++++++++++++++++++++ review_agent/regulatory_review/views.py | 4 ++ static/css/login.css | 24 +++++++++-- static/js/app.js | 29 ++++++++++++++ tests/test_application_form_fill_frontend.py | 29 ++++++++++++++ tests/test_file_summary_frontend.py | 27 ++++++++++++- tests/test_regulatory_frontend.py | 33 +++++++++++++++ 9 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 review_agent/notifications/presenter.py diff --git a/review_agent/application_form_fill/views.py b/review_agent/application_form_fill/views.py index fb147b4..70879ff 100644 --- a/review_agent/application_form_fill/views.py +++ b/review_agent/application_form_fill/views.py @@ -11,6 +11,7 @@ from review_agent.application_form_fill.workflow import ( start_application_form_fill_workflow, ) from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileSummaryBatch, WorkflowNodeRun +from review_agent.notifications.presenter import serialize_notification_records @require_http_methods(["GET"]) @@ -75,6 +76,7 @@ def batch_status(request, batch_id: int): workflow_type="application_form_fill", workflow_batch_id=batch.pk, ).order_by("id") + notifications = serialize_notification_records("application_form_fill", batch.pk) return JsonResponse( { "batch": { @@ -112,6 +114,8 @@ def batch_status(request, batch_id: int): } for export in exports ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, } ) diff --git a/review_agent/file_summary/views.py b/review_agent/file_summary/views.py index 860c13d..3fe4120 100644 --- a/review_agent/file_summary/views.py +++ b/review_agent/file_summary/views.py @@ -9,6 +9,7 @@ from django.views.decorators.http import require_http_methods from review_agent.models import ApplicationFormFillBatch, Conversation, ExportedSummaryFile, FileAttachment, Message from review_agent.models import FileSummaryBatch, WorkflowEvent +from review_agent.notifications.presenter import serialize_notification_records from .events import serialize_event from .paths import resolve_storage_path @@ -225,6 +226,7 @@ def batch_status(request, batch_id: int): batch = FileSummaryBatch.objects.filter(pk=batch_id, user=request.user).first() if not batch: raise Http404("批次不存在。") + notifications = serialize_notification_records("file_summary", batch.pk) return JsonResponse( { "batch": { @@ -249,6 +251,8 @@ def batch_status(request, batch_id: int): } for node in batch.node_runs.order_by("id") ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, } ) diff --git a/review_agent/notifications/presenter.py b/review_agent/notifications/presenter.py new file mode 100644 index 0000000..92d31b6 --- /dev/null +++ b/review_agent/notifications/presenter.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from review_agent.models import WorkflowNotificationRecord + + +def get_notification_records(workflow_type: str, batch_id: int): + return WorkflowNotificationRecord.objects.filter( + workflow_type=workflow_type, + workflow_batch_id=batch_id, + ).order_by("-created_at", "-id") + + +def serialize_notification_record(record: WorkflowNotificationRecord) -> dict[str, object]: + return { + "id": record.pk, + "channel": record.channel, + "target": record.target, + "receiver": record.at_display_name or record.target, + "identifier_type": record.at_identifier_type, + "identifier_masked": record.at_identifier_masked, + "send_status": record.send_status, + "status_label": notification_status_label(record), + "sent_at": record.sent_at.isoformat() if record.sent_at else "", + "created_at": record.created_at.isoformat(), + "error_code": record.error_code, + "error_message": record.error_message, + } + + +def serialize_notification_records(workflow_type: str, batch_id: int) -> list[dict[str, object]]: + return [serialize_notification_record(record) for record in get_notification_records(workflow_type, batch_id)] + + +def notification_status_label(record: WorkflowNotificationRecord) -> str: + labels = { + WorkflowNotificationRecord.SendStatus.SUCCESS: "飞书通知已发送", + WorkflowNotificationRecord.SendStatus.FAILED: "飞书通知失败", + WorkflowNotificationRecord.SendStatus.DISABLED: "飞书通知未启用", + WorkflowNotificationRecord.SendStatus.SKIPPED_DUPLICATE: "飞书通知已跳过重复发送", + WorkflowNotificationRecord.SendStatus.PENDING: "飞书通知待发送", + } + return labels.get(record.send_status, record.send_status) diff --git a/review_agent/regulatory_review/views.py b/review_agent/regulatory_review/views.py index ff52236..c244dea 100644 --- a/review_agent/regulatory_review/views.py +++ b/review_agent/regulatory_review/views.py @@ -8,6 +8,7 @@ from django.views.decorators.http import require_http_methods from django.contrib.auth.decorators import login_required from review_agent.models import FileSummaryBatch, RegulatoryReviewBatch, WorkflowNodeRun +from review_agent.notifications.presenter import serialize_notification_records from review_agent.regulatory_review.events import record_event from review_agent.regulatory_review.services.info_extract import ensure_regulatory_condition_candidates from review_agent.regulatory_review.services.rectification_review import review_missing_issues @@ -25,6 +26,7 @@ def batch_status(request, batch_id: int): workflow_type="regulatory_review", workflow_batch_id=batch.pk, ).order_by("id") + notifications = serialize_notification_records("regulatory_review", batch.pk) payload = { "batch": { "id": batch.pk, @@ -46,6 +48,8 @@ def batch_status(request, batch_id: int): } for node in nodes ], + "notifications": notifications, + "latest_notification": notifications[0] if notifications else None, } if batch.status == RegulatoryReviewBatch.Status.WAITING_USER and condition_candidates: payload["condition_confirmation"] = { diff --git a/static/css/login.css b/static/css/login.css index 212ead0..fa4ded9 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -887,7 +887,8 @@ input:focus { .attachment-item span, .workflow-card em, .workflow-card small, -.workflow-error { +.workflow-error, +.workflow-notification { color: var(--muted); font-size: 12px; } @@ -1042,18 +1043,33 @@ input:focus { .node-status span, .node-status small, -.workflow-error { +.workflow-error, +.workflow-notification { overflow-wrap: anywhere; word-break: break-word; } -.workflow-error { +.workflow-error, +.workflow-notification { margin: 0; padding: 8px 10px; border-radius: 6px; + line-height: 1.5; +} + +.workflow-error { + background: #fff1f0; + color: #b42318; +} + +.workflow-notification { + background: #f5fbf7; + color: #166534; +} + +.workflow-notification[data-notification-status="failed"] { background: #fff1f0; color: #b42318; - line-height: 1.5; } .status-running, diff --git a/static/js/app.js b/static/js/app.js index a1f4a99..58e1230 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -734,6 +734,34 @@ return html; } + function notificationLabel(notification) { + if (!notification) { + return "暂无飞书通知记录"; + } + return notification.status_label || notification.send_status || "飞书通知状态未知"; + } + + function renderNotificationSummary(card, notification) { + var panel = card.querySelector(".workflow-notification"); + if (!panel) { + panel = document.createElement("p"); + panel.className = "workflow-notification"; + card.insertBefore(panel, card.querySelector("ol")); + } + var text = notificationLabel(notification); + if (notification && notification.receiver) { + text += " · " + notification.receiver; + } + if (notification && notification.sent_at) { + text += " · " + notification.sent_at; + } + if (notification && notification.error_message) { + text += " · " + notification.error_message; + } + panel.textContent = text; + panel.setAttribute("data-notification-status", notification ? notification.send_status || "" : "none"); + } + async function refreshWorkflowCard(batchId, workflow_type) { if (!summaryPanel || !batchId) { return ""; @@ -788,6 +816,7 @@ } else if (riskSummary) { riskSummary.remove(); } + renderNotificationSummary(card, payload.latest_notification); var list = card.querySelector("ol"); list.innerHTML = ""; (payload.nodes || []).forEach(function (node) { diff --git a/tests/test_application_form_fill_frontend.py b/tests/test_application_form_fill_frontend.py index ae16656..7df21f6 100644 --- a/tests/test_application_form_fill_frontend.py +++ b/tests/test_application_form_fill_frontend.py @@ -46,3 +46,32 @@ def test_frontend_selects_application_form_fill_status_url_and_terminal_status() assert 'workflow_type === "application_form_fill"' in script assert "data-application-form-fill-status-url-template" in script assert 'status === "partial_success"' in script + + +def test_application_form_fill_status_includes_no_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-AFF") + batch = ApplicationFormFillBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="AFF-FEISHU", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/application-form-fill/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"] is None + assert payload["notifications"] == [] + + +def test_frontend_renders_feishu_notification_status(): + script = open("static/js/app.js", encoding="utf-8").read() + css = open("static/css/login.css", encoding="utf-8").read() + + assert "renderNotificationSummary" in script + assert "暂无飞书通知记录" in script + assert "workflow-notification" in script + assert ".workflow-notification" in css diff --git a/tests/test_file_summary_frontend.py b/tests/test_file_summary_frontend.py index 1355619..27e9675 100644 --- a/tests/test_file_summary_frontend.py +++ b/tests/test_file_summary_frontend.py @@ -1,7 +1,7 @@ import pytest from django.urls import reverse -from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun +from review_agent.models import Conversation, FileAttachment, FileSummaryBatch, Message, WorkflowNodeRun, WorkflowNotificationRecord pytestmark = pytest.mark.django_db @@ -223,6 +223,31 @@ def test_frontend_renders_workflow_error_messages(): assert ".workflow-error" in css +def test_file_summary_status_includes_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + batch = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-FEISHU") + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + dedupe_key=f"file_summary:{batch.pk}:{batch.status}", + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + target="负责人", + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_title="自动汇总完成", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/file-summary/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"]["status_label"] == "飞书通知已发送" + assert payload["notifications"][0]["target"] == "负责人" + + def test_frontend_renders_workflow_batches_as_carousel(): script = open("static/js/app.js", encoding="utf-8").read() css = open("static/css/login.css", encoding="utf-8").read() diff --git a/tests/test_regulatory_frontend.py b/tests/test_regulatory_frontend.py index 013920e..de59447 100644 --- a/tests/test_regulatory_frontend.py +++ b/tests/test_regulatory_frontend.py @@ -8,6 +8,7 @@ from review_agent.models import ( RegulatoryArtifact, RegulatoryNotificationRecord, RegulatoryReviewBatch, + WorkflowNotificationRecord, WorkflowNodeRun, ) @@ -230,3 +231,35 @@ def test_frontend_keeps_single_condition_confirmation_prompt(): assert "data-condition-confirmation-card" in script assert "removeStaleConditionConfirmationCards" in script assert '[data-condition-confirmation-card]' in script + + +def test_regulatory_status_includes_failed_feishu_notification(client, django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + conversation = Conversation.objects.create(user=user, title="会话") + summary = FileSummaryBatch.objects.create(conversation=conversation, user=user, batch_no="FS-RR") + batch = RegulatoryReviewBatch.objects.create( + conversation=conversation, + user=user, + source_summary_batch=summary, + batch_no="RR-FEISHU", + ) + WorkflowNotificationRecord.objects.create( + workflow_type="regulatory_review", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status=batch.status, + dedupe_key=f"regulatory_review:{batch.pk}:{batch.status}", + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + target="负责人", + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_title="法规核查完成", + error_message="bad receive_id", + ) + client.force_login(user) + + response = client.get(f"/api/review-agent/regulatory-review/{batch.pk}/status/") + + payload = response.json() + assert payload["latest_notification"]["status_label"] == "飞书通知失败" + assert payload["latest_notification"]["error_message"] == "bad receive_id"