diff --git a/config/settings.py b/config/settings.py index 4cb4de2..c996115 100644 --- a/config/settings.py +++ b/config/settings.py @@ -126,6 +126,24 @@ SILICONFLOW_EMBEDDING_MODEL = os.environ.get( ) SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024")) +FEISHU_NOTIFY_ENABLED = os.environ.get("FEISHU_NOTIFY_ENABLED", "false").lower() == "true" +FEISHU_NOTIFY_CHANNEL = os.environ.get("FEISHU_NOTIFY_CHANNEL", "feishu_api") +FEISHU_APP_ID = os.environ.get("FEISHU_APP_ID", "") +FEISHU_APP_SECRET = os.environ.get("FEISHU_APP_SECRET", "") +FEISHU_DEFAULT_USER_OPEN_ID = os.environ.get("FEISHU_DEFAULT_USER_OPEN_ID", "") +FEISHU_DEFAULT_USER_ID = os.environ.get("FEISHU_DEFAULT_USER_ID", "") +FEISHU_DEFAULT_TARGET_NAME = os.environ.get("FEISHU_DEFAULT_TARGET_NAME", "默认飞书接收人") +FEISHU_TENANT_TOKEN_CACHE_SECONDS = int(os.environ.get("FEISHU_TENANT_TOKEN_CACHE_SECONDS", "6600")) +FEISHU_TOKEN_API_URL = os.environ.get( + "FEISHU_TOKEN_API_URL", + "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", +) +FEISHU_MESSAGE_API_URL = os.environ.get( + "FEISHU_MESSAGE_API_URL", + "https://open.feishu.cn/open-apis/im/v1/messages", +) +PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "http://127.0.0.1:8000") + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/review_agent/admin.py b/review_agent/admin.py new file mode 100644 index 0000000..4465f6f --- /dev/null +++ b/review_agent/admin.py @@ -0,0 +1,74 @@ +from django.contrib import admin + +from review_agent.models import ( + FeishuAccessTokenCache, + FeishuQuestionLog, + FeishuUserMapping, + WorkflowNotificationRecord, +) + + +@admin.register(FeishuUserMapping) +class FeishuUserMappingAdmin(admin.ModelAdmin): + list_display = ( + "system_user", + "feishu_display_name", + "feishu_open_id", + "feishu_user_id", + "feishu_mobile", + "is_active", + "updated_at", + ) + list_filter = ("is_active",) + search_fields = ( + "system_user__username", + "feishu_display_name", + "feishu_open_id", + "feishu_user_id", + "feishu_mobile", + ) + readonly_fields = ("created_at", "updated_at") + + +@admin.register(FeishuAccessTokenCache) +class FeishuAccessTokenCacheAdmin(admin.ModelAdmin): + list_display = ("app_id_hash", "expires_at", "updated_at", "has_error") + search_fields = ("app_id_hash", "error_message") + readonly_fields = ("created_at", "updated_at") + + @admin.display(boolean=True, description="有错误") + def has_error(self, obj: FeishuAccessTokenCache) -> bool: + return bool(obj.error_message) + + +@admin.register(WorkflowNotificationRecord) +class WorkflowNotificationRecordAdmin(admin.ModelAdmin): + list_display = ( + "workflow_type", + "workflow_batch_no", + "workflow_status", + "channel", + "send_status", + "target", + "sent_at", + "created_at", + ) + list_filter = ("workflow_type", "channel", "send_status", "workflow_status") + search_fields = ("workflow_batch_no", "dedupe_key", "target", "error_message") + readonly_fields = ("created_at", "updated_at") + + +@admin.register(FeishuQuestionLog) +class FeishuQuestionLogAdmin(admin.ModelAdmin): + list_display = ( + "system_user", + "source_type", + "intent", + "permission_result", + "status", + "processed_at", + "created_at", + ) + list_filter = ("source_type", "intent", "permission_result", "status") + search_fields = ("system_user__username", "question_text", "answer_summary", "message_id") + readonly_fields = ("created_at",) diff --git a/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py b/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py new file mode 100644 index 0000000..dc1cb12 --- /dev/null +++ b/review_agent/migrations/0007_feishuaccesstokencache_feishuusermapping_and_more.py @@ -0,0 +1,352 @@ +# Generated by Django 5.2.14 on 2026-06-07 14:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("review_agent", "0006_alter_exportedsummaryfile_export_type_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FeishuAccessTokenCache", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("app_id_hash", models.CharField(max_length=128, unique=True)), + ("tenant_access_token", models.TextField(blank=True, default="")), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("error_message", models.TextField(blank=True, default="")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "ra_feishu_access_token_cache", + "ordering": ["-updated_at", "-id"], + "indexes": [ + models.Index( + fields=["app_id_hash"], name="idx_ra_feishu_token_app" + ), + models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"), + ], + }, + ), + migrations.CreateModel( + name="FeishuUserMapping", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "feishu_display_name", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_open_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_user_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_mobile", + models.CharField(blank=True, default="", max_length=40), + ), + ("is_active", models.BooleanField(default=True)), + ("remark", models.CharField(blank=True, default="", max_length=255)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "system_user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="feishu_mapping", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_feishu_user_mapping", + "ordering": ["system_user__username", "id"], + }, + ), + migrations.CreateModel( + name="FeishuQuestionLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "feishu_open_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "feishu_user_id", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "source_type", + models.CharField( + choices=[ + ("private_chat", "私聊"), + ("group_mention", "群聊 @"), + ("simulate", "本地模拟"), + ], + default="simulate", + max_length=30, + ), + ), + ( + "message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("question_text", models.TextField()), + ("intent", models.CharField(blank=True, default="", max_length=60)), + ("query_object", models.JSONField(blank=True, default=dict)), + ("answer_summary", models.TextField(blank=True, default="")), + ( + "permission_result", + models.CharField(blank=True, default="", max_length=40), + ), + ( + "status", + models.CharField( + choices=[ + ("success", "成功"), + ("failed", "失败"), + ("ignored", "忽略"), + ], + default="success", + max_length=30, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "system_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="feishu_question_logs", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "feishu_mapping", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="question_logs", + to="review_agent.feishuusermapping", + ), + ), + ], + options={ + "db_table": "ra_feishu_question_log", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.CreateModel( + name="WorkflowNotificationRecord", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("workflow_type", models.CharField(max_length=40)), + ("workflow_batch_id", models.PositiveBigIntegerField()), + ("workflow_batch_no", models.CharField(max_length=80)), + ("workflow_status", models.CharField(max_length=40)), + ("dedupe_key", models.CharField(max_length=160)), + ( + "channel", + models.CharField( + choices=[ + ("mock", "模拟"), + ("disabled", "未启用"), + ("feishu_api", "飞书 API"), + ], + default="mock", + max_length=40, + ), + ), + ("target", models.CharField(blank=True, default="", max_length=160)), + ( + "at_display_name", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "at_identifier_type", + models.CharField(blank=True, default="", max_length=30), + ), + ( + "at_identifier_masked", + models.CharField(blank=True, default="", max_length=120), + ), + ( + "send_status", + models.CharField( + choices=[ + ("pending", "待发送"), + ("success", "成功"), + ("failed", "失败"), + ("skipped_duplicate", "重复跳过"), + ("disabled", "未启用"), + ], + default="pending", + max_length=30, + ), + ), + ("message_title", models.CharField(max_length=200)), + ("message_summary", models.TextField(blank=True, default="")), + ( + "result_url", + models.CharField(blank=True, default="", max_length=500), + ), + ( + "external_message_id", + models.CharField(blank=True, default="", max_length=120), + ), + ("error_code", models.CharField(blank=True, default="", max_length=80)), + ("error_message", models.TextField(blank=True, default="")), + ( + "request_duration_ms", + models.PositiveIntegerField(blank=True, null=True), + ), + ("sent_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "feishu_mapping", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="notification_records", + to="review_agent.feishuusermapping", + ), + ), + ( + "trigger_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="workflow_notification_records", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "ra_workflow_notification_record", + "ordering": ["-created_at", "-id"], + }, + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_open_id"], name="idx_ra_feishu_map_open" + ), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_user_id"], name="idx_ra_feishu_map_userid" + ), + ), + migrations.AddIndex( + model_name="feishuusermapping", + index=models.Index( + fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["system_user", "created_at"], + name="idx_ra_feishu_q_user_created", + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["intent", "created_at"], name="idx_ra_feishu_q_intent" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index( + fields=["status", "created_at"], name="idx_ra_feishu_q_status" + ), + ), + migrations.AddIndex( + model_name="feishuquestionlog", + index=models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["workflow_type", "workflow_batch_id"], + name="idx_ra_notify_workflow", + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["send_status", "created_at"], name="idx_ra_notify_status" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["workflow_batch_no"], name="idx_ra_notify_batch_no" + ), + ), + migrations.AddIndex( + model_name="workflownotificationrecord", + index=models.Index( + fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status" + ), + ), + ] diff --git a/review_agent/models.py b/review_agent/models.py index 541a209..357ddca 100644 --- a/review_agent/models.py +++ b/review_agent/models.py @@ -754,3 +754,202 @@ class ApplicationFormFillNotificationRecord(models.Model): models.Index(fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient"), models.Index(fields=["send_status", "retry_count"], name="idx_ra_aff_notify_status"), ] + + +class FeishuUserMapping(models.Model): + """Maps a system user to Feishu identifiers maintained by Admin.""" + + system_user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="feishu_mapping", + ) + feishu_display_name = models.CharField(max_length=120, blank=True, default="") + feishu_open_id = models.CharField(max_length=120, blank=True, default="") + feishu_user_id = models.CharField(max_length=120, blank=True, default="") + feishu_mobile = models.CharField(max_length=40, blank=True, default="") + is_active = models.BooleanField(default=True) + remark = models.CharField(max_length=255, blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_feishu_user_mapping" + ordering = ["system_user__username", "id"] + indexes = [ + models.Index(fields=["is_active"], name="idx_ra_feishu_map_active"), + models.Index(fields=["feishu_open_id"], name="idx_ra_feishu_map_open"), + models.Index(fields=["feishu_user_id"], name="idx_ra_feishu_map_userid"), + models.Index(fields=["feishu_mobile"], name="idx_ra_feishu_map_mobile"), + ] + + def preferred_identifier(self) -> tuple[str, str]: + if self.feishu_open_id: + return "open_id", self.feishu_open_id + if self.feishu_user_id: + return "user_id", self.feishu_user_id + if self.feishu_mobile: + return "mobile", self.feishu_mobile + return "missing", "" + + def __str__(self) -> str: + return self.feishu_display_name or self.system_user.get_username() + + +class FeishuAccessTokenCache(models.Model): + """Caches Feishu tenant_access_token until its expiry time.""" + + app_id_hash = models.CharField(max_length=128, unique=True) + tenant_access_token = models.TextField(blank=True, default="") + expires_at = models.DateTimeField(null=True, blank=True) + error_message = models.TextField(blank=True, default="") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_feishu_access_token_cache" + ordering = ["-updated_at", "-id"] + indexes = [ + models.Index(fields=["app_id_hash"], name="idx_ra_feishu_token_app"), + models.Index(fields=["expires_at"], name="idx_ra_feishu_token_exp"), + ] + + def is_valid(self, now=None) -> bool: + from django.utils import timezone + + current = now or timezone.now() + return bool(self.tenant_access_token and self.expires_at and self.expires_at > current) + + def __str__(self) -> str: + return f"Feishu token cache {self.app_id_hash[:8]}" + + +class WorkflowNotificationRecord(models.Model): + """Stores unified notification send records for all workflow types.""" + + class Channel(models.TextChoices): + MOCK = "mock", "模拟" + DISABLED = "disabled", "未启用" + FEISHU_API = "feishu_api", "飞书 API" + + class SendStatus(models.TextChoices): + PENDING = "pending", "待发送" + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + SKIPPED_DUPLICATE = "skipped_duplicate", "重复跳过" + DISABLED = "disabled", "未启用" + + workflow_type = models.CharField(max_length=40) + workflow_batch_id = models.PositiveBigIntegerField() + workflow_batch_no = models.CharField(max_length=80) + workflow_status = models.CharField(max_length=40) + dedupe_key = models.CharField(max_length=160) + trigger_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="workflow_notification_records", + ) + feishu_mapping = models.ForeignKey( + FeishuUserMapping, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="notification_records", + ) + channel = models.CharField(max_length=40, choices=Channel.choices, default=Channel.MOCK) + target = models.CharField(max_length=160, blank=True, default="") + at_display_name = models.CharField(max_length=120, blank=True, default="") + at_identifier_type = models.CharField(max_length=30, blank=True, default="") + at_identifier_masked = models.CharField(max_length=120, blank=True, default="") + send_status = models.CharField( + max_length=30, + choices=SendStatus.choices, + default=SendStatus.PENDING, + ) + message_title = models.CharField(max_length=200) + message_summary = models.TextField(blank=True, default="") + result_url = models.CharField(max_length=500, blank=True, default="") + external_message_id = models.CharField(max_length=120, blank=True, default="") + error_code = models.CharField(max_length=80, blank=True, default="") + error_message = models.TextField(blank=True, default="") + request_duration_ms = models.PositiveIntegerField(null=True, blank=True) + sent_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "ra_workflow_notification_record" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["workflow_type", "workflow_batch_id"], name="idx_ra_notify_workflow"), + models.Index(fields=["trigger_user", "created_at"], name="idx_ra_notify_user_created"), + models.Index(fields=["send_status", "created_at"], name="idx_ra_notify_status"), + models.Index(fields=["workflow_batch_no"], name="idx_ra_notify_batch_no"), + models.Index(fields=["dedupe_key", "send_status"], name="idx_ra_notify_dedupe_status"), + ] + + @classmethod + def build_dedupe_key(cls, workflow_type: str, workflow_batch_id: int, workflow_status: str) -> str: + return f"{workflow_type}:{workflow_batch_id}:{workflow_status}" + + @classmethod + def already_successfully_sent(cls, dedupe_key: str) -> bool: + return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists() + + def __str__(self) -> str: + return f"{self.workflow_type} {self.workflow_batch_no} {self.send_status}" + + +class FeishuQuestionLog(models.Model): + """Records reserved Feishu question handling without storing full answers.""" + + class SourceType(models.TextChoices): + PRIVATE_CHAT = "private_chat", "私聊" + GROUP_MENTION = "group_mention", "群聊 @" + SIMULATE = "simulate", "本地模拟" + + class Status(models.TextChoices): + SUCCESS = "success", "成功" + FAILED = "failed", "失败" + IGNORED = "ignored", "忽略" + + system_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="feishu_question_logs", + ) + feishu_mapping = models.ForeignKey( + FeishuUserMapping, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="question_logs", + ) + feishu_open_id = models.CharField(max_length=120, blank=True, default="") + feishu_user_id = models.CharField(max_length=120, blank=True, default="") + source_type = models.CharField(max_length=30, choices=SourceType.choices, default=SourceType.SIMULATE) + message_id = models.CharField(max_length=120, blank=True, default="") + question_text = models.TextField() + intent = models.CharField(max_length=60, blank=True, default="") + query_object = models.JSONField(default=dict, blank=True) + answer_summary = models.TextField(blank=True, default="") + permission_result = models.CharField(max_length=40, blank=True, default="") + status = models.CharField(max_length=30, choices=Status.choices, default=Status.SUCCESS) + error_message = models.TextField(blank=True, default="") + processed_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "ra_feishu_question_log" + ordering = ["-created_at", "-id"] + indexes = [ + models.Index(fields=["system_user", "created_at"], name="idx_ra_feishu_q_user_created"), + models.Index(fields=["intent", "created_at"], name="idx_ra_feishu_q_intent"), + models.Index(fields=["status", "created_at"], name="idx_ra_feishu_q_status"), + models.Index(fields=["message_id"], name="idx_ra_feishu_q_message"), + ] + + def __str__(self) -> str: + return f"{self.intent or 'unknown'} {self.status}" diff --git a/tests/test_feishu_models.py b/tests/test_feishu_models.py new file mode 100644 index 0000000..95c6657 --- /dev/null +++ b/tests/test_feishu_models.py @@ -0,0 +1,104 @@ +from django.utils import timezone +import pytest + +from review_agent.models import ( + Conversation, + FeishuAccessTokenCache, + FeishuQuestionLog, + FeishuUserMapping, + FileSummaryBatch, + WorkflowNotificationRecord, +) + + +pytestmark = pytest.mark.django_db + + +def test_feishu_user_mapping_preferred_identifier(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + mapping = FeishuUserMapping.objects.create( + system_user=user, + feishu_display_name="负责人", + feishu_open_id="ou_open", + feishu_user_id="user_id", + feishu_mobile="13800000000", + ) + + assert mapping.preferred_identifier() == ("open_id", "ou_open") + + mapping.feishu_open_id = "" + assert mapping.preferred_identifier() == ("user_id", "user_id") + + mapping.feishu_user_id = "" + assert mapping.preferred_identifier() == ("mobile", "13800000000") + + +def test_feishu_access_token_cache_expiry(): + now = timezone.now() + cache = FeishuAccessTokenCache.objects.create( + app_id_hash="hash", + tenant_access_token="token", + expires_at=now + timezone.timedelta(minutes=5), + ) + + assert cache.is_valid(now=now) + + cache.expires_at = now - timezone.timedelta(seconds=1) + assert not cache.is_valid(now=now) + + +def test_workflow_notification_success_dedupe_only_success(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", + status=FileSummaryBatch.Status.SUCCESS, + ) + dedupe_key = WorkflowNotificationRecord.build_dedupe_key("file_summary", batch.pk, "success") + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status="success", + dedupe_key=dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.FAILED, + message_title="失败通知", + ) + + assert not WorkflowNotificationRecord.already_successfully_sent(dedupe_key) + + WorkflowNotificationRecord.objects.create( + workflow_type="file_summary", + workflow_batch_id=batch.pk, + workflow_batch_no=batch.batch_no, + workflow_status="success", + dedupe_key=dedupe_key, + trigger_user=user, + channel=WorkflowNotificationRecord.Channel.FEISHU_API, + send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, + message_title="成功通知", + ) + + assert WorkflowNotificationRecord.already_successfully_sent(dedupe_key) + + +def test_feishu_question_log_records_summary_without_full_answer(django_user_model): + user = django_user_model.objects.create_user(username="owner", password="pass") + log = FeishuQuestionLog.objects.create( + system_user=user, + source_type=FeishuQuestionLog.SourceType.SIMULATE, + question_text="查最新法规核查", + intent="batch_status", + query_object={"workflow_type": "regulatory_review", "latest": True}, + answer_summary="RR-001 成功,阻断项 0,高风险 1。", + permission_result="allowed", + status=FeishuQuestionLog.Status.SUCCESS, + processed_at=timezone.now(), + ) + + assert "完整回答" not in log.answer_summary + assert log.query_object["latest"] is True