from django.conf import settings from django.db import models class Conversation(models.Model): """Stores a user's review-agent conversation shell.""" user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_conversations", ) title = models.CharField(max_length=120, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-updated_at", "-id"] def __str__(self) -> str: return self.title or f"对话 {self.pk}" class Message(models.Model): """Stores one user or assistant message in a conversation.""" class Role(models.TextChoices): USER = "user", "用户" ASSISTANT = "assistant", "助手" conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, related_name="messages", ) role = models.CharField(max_length=20, choices=Role.choices) content = models.TextField() created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ["created_at", "id"] def __str__(self) -> str: return f"{self.get_role_display()} - {self.conversation_id}" class FileAttachment(models.Model): """Stores an uploaded file version for one conversation.""" class UploadStatus(models.TextChoices): UPLOADED = "uploaded", "已上传" BOUND = "bound", "已绑定" DELETED = "deleted", "已删除" conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, related_name="file_attachments", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_file_attachments", ) original_name = models.CharField(max_length=255) version_no = models.PositiveIntegerField(default=1) is_active = models.BooleanField(default=True) storage_path = models.CharField(max_length=500) file_size = models.BigIntegerField(default=0) content_type = models.CharField(max_length=120, blank=True, default="") upload_status = models.CharField( max_length=20, choices=UploadStatus.choices, default=UploadStatus.UPLOADED, ) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "ra_file_attachment" ordering = ["-created_at", "-id"] constraints = [ models.UniqueConstraint( fields=["conversation", "original_name", "version_no"], name="uq_ra_attachment_conv_name_version", ) ] indexes = [ models.Index( fields=["conversation", "created_at"], name="idx_ra_attachment_conv_created", ), models.Index( fields=["user", "created_at"], name="idx_ra_attachment_user_created", ), models.Index( fields=["conversation", "original_name", "is_active"], name="idx_ra_attachment_active", ), ] def __str__(self) -> str: return f"{self.original_name} v{self.version_no}" class FileSummaryBatch(models.Model): """Tracks one automatic file inventory and page-count workflow run.""" class Status(models.TextChoices): PENDING = "pending", "待执行" RUNNING = "running", "执行中" SUCCESS = "success", "成功" FAILED = "failed", "失败" conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, related_name="file_summary_batches", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_file_summary_batches", ) trigger_message = models.ForeignKey( Message, on_delete=models.SET_NULL, null=True, blank=True, related_name="triggered_file_summary_batches", ) batch_no = models.CharField(max_length=64, unique=True) product_name = models.CharField(max_length=200, blank=True, default="") status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) total_files = models.IntegerField(default=0) supported_files = models.IntegerField(default=0) success_files = models.IntegerField(default=0) failed_files = models.IntegerField(default=0) unsupported_files = models.IntegerField(default=0) uncertain_files = models.IntegerField(default=0) total_pages = models.IntegerField(default=0) work_dir = models.CharField(max_length=500, blank=True, default="") error_message = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "ra_file_summary_batch" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["conversation", "created_at"], name="idx_ra_batch_conv_created"), models.Index(fields=["user", "created_at"], name="idx_ra_batch_user_created"), models.Index(fields=["status", "created_at"], name="idx_ra_batch_status"), ] def __str__(self) -> str: return self.batch_no class FileSummaryBatchAttachment(models.Model): """Binds a workflow batch to the exact attachment versions it uses.""" class SourceRole(models.TextChoices): ARCHIVE = "archive", "压缩包" MULTI_FILE = "multi_file", "多文件" batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, related_name="batch_attachments", ) attachment = models.ForeignKey( FileAttachment, on_delete=models.CASCADE, related_name="batch_bindings", ) source_role = models.CharField( max_length=20, choices=SourceRole.choices, default=SourceRole.MULTI_FILE, ) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "ra_file_summary_batch_attachment" constraints = [ models.UniqueConstraint( fields=["batch", "attachment"], name="uq_ra_batch_attachment", ) ] indexes = [ models.Index( fields=["batch", "created_at"], name="idx_ra_batch_attachment_batch", ), models.Index(fields=["attachment"], name="idx_ra_batch_attach_file"), ] class FileSummaryItem(models.Model): """Stores one scanned file and its page-count result.""" class StatisticsStatus(models.TextChoices): SUCCESS = "success", "成功" FAILED = "failed", "失败" UNSUPPORTED = "unsupported", "不支持" UNCERTAIN = "uncertain", "不确定" SKIPPED = "skipped", "跳过" batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, related_name="items", ) file_index = models.PositiveIntegerField() directory_level = models.CharField(max_length=300, blank=True, default="") file_name = models.CharField(max_length=255) file_type = models.CharField(max_length=20) relative_path = models.CharField(max_length=500) storage_path = models.CharField(max_length=500) page_count = models.IntegerField(null=True, blank=True) statistics_status = models.CharField( max_length=20, choices=StatisticsStatus.choices, default=StatisticsStatus.SKIPPED, ) retry_count = models.PositiveIntegerField(default=0) 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_file_summary_item" ordering = ["file_index", "id"] constraints = [ models.UniqueConstraint( fields=["batch", "relative_path"], name="uq_ra_item_batch_relative_path", ) ] indexes = [ models.Index(fields=["batch", "file_index"], name="idx_ra_item_batch_index"), models.Index(fields=["batch", "statistics_status"], name="idx_ra_item_batch_status"), models.Index(fields=["batch", "file_type"], name="idx_ra_item_batch_type"), ] class WorkflowNodeRun(models.Model): """Stores recoverable status for one workflow node.""" class Status(models.TextChoices): PENDING = "pending", "等待中" RUNNING = "running", "执行中" WAITING_USER = "waiting_user", "等待用户确认" RETRYING = "retrying", "重试中" SUCCESS = "success", "成功" FAILED = "failed", "失败" SKIPPED = "skipped", "跳过" batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, null=True, blank=True, related_name="node_runs", ) workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) node_group = models.CharField(max_length=40, blank=True, default="file_summary") node_code = models.CharField(max_length=40) node_name = models.CharField(max_length=80) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) progress = models.PositiveIntegerField(default=0) message = models.TextField(blank=True, default="") started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "ra_workflow_node_run" constraints = [ models.UniqueConstraint(fields=["batch", "node_code"], name="uq_ra_node_batch_code") ] indexes = [ models.Index(fields=["batch", "status"], name="idx_ra_node_batch_status"), models.Index( fields=["workflow_type", "workflow_batch_id"], name="idx_ra_node_workflow", ), ] class WorkflowEvent(models.Model): """Persists workflow events for SSE replay and diagnostics.""" batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, null=True, blank=True, related_name="events", ) workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, null=True, blank=True, related_name="workflow_events", ) event_type = models.CharField(max_length=40) payload = models.JSONField(default=dict) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "ra_workflow_event" ordering = ["id"] indexes = [ models.Index(fields=["batch", "id"], name="idx_ra_event_batch_id"), models.Index(fields=["batch", "created_at"], name="idx_ra_event_batch_created"), models.Index( fields=["workflow_type", "workflow_batch_id", "id"], name="idx_ra_event_workflow_id", ), ] class ExportedSummaryFile(models.Model): """Stores generated report files for permission-checked download.""" class ExportType(models.TextChoices): MARKDOWN = "markdown", "Markdown" EXCEL = "excel", "Excel" JSON = "json", "JSON" WORD = "word", "Word" PDF = "pdf", "PDF" class Status(models.TextChoices): SUCCESS = "success", "成功" FAILED = "failed", "失败" batch = models.ForeignKey( FileSummaryBatch, on_delete=models.CASCADE, related_name="exports", ) workflow_type = models.CharField(max_length=40, blank=True, default="file_summary") workflow_batch_id = models.PositiveBigIntegerField(null=True, blank=True) export_category = models.CharField(max_length=40, blank=True, default="summary") export_type = models.CharField(max_length=20, choices=ExportType.choices) file_name = models.CharField(max_length=255) storage_path = models.CharField(max_length=500) status = models.CharField(max_length=20, choices=Status.choices, default=Status.SUCCESS) error_message = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "ra_exported_summary_file" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["batch", "export_type"], name="idx_ra_export_batch_type"), models.Index(fields=["batch", "created_at"], name="idx_ra_export_batch_created"), models.Index( fields=["workflow_type", "workflow_batch_id"], name="idx_ra_export_workflow", ), ] class RegulatoryRuleVersion(models.Model): """Tracks the local regulatory rule YAML and its matching RAG index.""" class Status(models.TextChoices): ACTIVE = "active", "启用" OUTDATED = "outdated", "待更新" DISABLED = "disabled", "停用" code = models.CharField(max_length=80, unique=True) name = models.CharField(max_length=160) yaml_path = models.CharField(max_length=500) yaml_hash = models.CharField(max_length=128) rag_collection = models.CharField(max_length=120, blank=True, default="") rag_index_version = models.CharField(max_length=80, blank=True, default="") rag_index_hash = models.CharField(max_length=128, blank=True, default="") status = models.CharField(max_length=20, choices=Status.choices, default=Status.ACTIVE) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "ra_regulatory_rule_version" ordering = ["-updated_at", "-id"] indexes = [ models.Index(fields=["code", "status"], name="idx_ra_rule_code_status"), ] def __str__(self) -> str: return self.code class ApplicationFormFillBatch(models.Model): """Tracks one application-form auto-fill workflow run.""" class Status(models.TextChoices): PENDING = "pending", "待执行" RUNNING = "running", "执行中" WAITING_USER = "waiting_user", "等待用户" SUCCESS = "success", "成功" PARTIAL_SUCCESS = "partial_success", "部分成功" FAILED = "failed", "失败" CANCELLED = "cancelled", "已取消" class RegistrationTypeSource(models.TextChoices): USER_MESSAGE = "user_message", "用户话语" REGULATORY_BATCH = "regulatory_batch", "法规核查批次" FILE_EXTRACT = "file_extract", "文件抽取" UNKNOWN = "unknown", "未知" conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, related_name="application_form_fill_batches", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_application_form_fill_batches", ) trigger_message = models.ForeignKey( Message, on_delete=models.SET_NULL, null=True, blank=True, related_name="triggered_application_form_fill_batches", ) source_summary_batch = models.ForeignKey( FileSummaryBatch, on_delete=models.PROTECT, related_name="application_form_fill_batches", ) source_regulatory_batch = models.ForeignKey( "RegulatoryReviewBatch", on_delete=models.SET_NULL, null=True, blank=True, related_name="application_form_fill_batches", ) batch_no = models.CharField(max_length=64, unique=True) status = models.CharField(max_length=30, choices=Status.choices, default=Status.PENDING) requested_templates = models.JSONField(default=list, blank=True) selected_templates = models.JSONField(default=list, blank=True) output_types = models.JSONField(default=list, blank=True) registration_type = models.CharField(max_length=80, blank=True, default="") registration_type_source = models.CharField( max_length=40, choices=RegistrationTypeSource.choices, default=RegistrationTypeSource.UNKNOWN, ) product_name = models.CharField(max_length=200, blank=True, default="") conflict_summary = models.JSONField(default=list, blank=True) risk_notes = models.JSONField(default=list, blank=True) template_config_version = models.CharField(max_length=80, blank=True, default="") template_config_hash = models.CharField(max_length=128, blank=True, default="") work_dir = models.CharField(max_length=500, blank=True, default="") error_message = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) archived_at = models.DateTimeField(null=True, blank=True) is_deleted = models.BooleanField(default=False) class Meta: db_table = "ra_application_form_fill_batch" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["conversation", "status"], name="idx_ra_aff_batch_conv_status"), models.Index(fields=["source_summary_batch"], name="idx_ra_aff_batch_summary"), models.Index(fields=["source_regulatory_batch"], name="idx_ra_aff_batch_regulatory"), models.Index(fields=["user", "created_at"], name="idx_ra_aff_batch_user_created"), models.Index(fields=["created_at"], name="idx_ra_aff_batch_created"), ] def __str__(self) -> str: return self.batch_no class RegulatoryReviewBatch(models.Model): """Tracks one NMPA regulatory review workflow run.""" class Status(models.TextChoices): PENDING = "pending", "待执行" RUNNING = "running", "执行中" WAITING_USER = "waiting_user", "等待用户确认" SUCCESS = "success", "成功" FAILED = "failed", "失败" conversation = models.ForeignKey( Conversation, on_delete=models.CASCADE, related_name="regulatory_review_batches", ) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="review_regulatory_batches", ) trigger_message = models.ForeignKey( Message, on_delete=models.SET_NULL, null=True, blank=True, related_name="triggered_regulatory_batches", ) source_summary_batch = models.ForeignKey( FileSummaryBatch, on_delete=models.PROTECT, related_name="regulatory_review_batches", ) rule_version = models.ForeignKey( RegulatoryRuleVersion, on_delete=models.SET_NULL, null=True, blank=True, related_name="review_batches", ) batch_no = models.CharField(max_length=64, unique=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) condition_json = models.JSONField(default=dict, blank=True) risk_summary = models.JSONField(default=dict, blank=True) work_dir = models.CharField(max_length=500, blank=True, default="") error_message = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) started_at = models.DateTimeField(null=True, blank=True) finished_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "ra_regulatory_review_batch" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["conversation", "created_at"], name="idx_ra_rr_batch_conv"), models.Index(fields=["user", "created_at"], name="idx_ra_rr_batch_user"), models.Index(fields=["status", "created_at"], name="idx_ra_rr_batch_status"), ] def __str__(self) -> str: return self.batch_no class RegulatoryIssue(models.Model): """Stores one regulatory finding after risk consolidation.""" class Severity(models.TextChoices): BLOCKING = "blocking", "阻断项" HIGH = "high", "高风险" MEDIUM = "medium", "中风险" LOW = "low", "低风险" INFO = "info", "提示" class Category(models.TextChoices): COMPLETENESS = "completeness", "完整性" STRUCTURE = "structure", "章节" CONSISTENCY = "consistency", "一致性" RAG = "rag", "法规依据" class Status(models.TextChoices): OPEN = "open", "待处理" RESOLVED = "resolved", "已整改" ACCEPTED = "accepted", "已接受" REVIEW_PASSED = "review_passed", "复核通过" REVIEW_FAILED = "review_failed", "复核未通过" batch = models.ForeignKey( RegulatoryReviewBatch, on_delete=models.CASCADE, related_name="issues", ) rule_code = models.CharField(max_length=120, blank=True, default="") category = models.CharField(max_length=40, choices=Category.choices) severity = models.CharField(max_length=20, choices=Severity.choices) title = models.CharField(max_length=255) detail = models.TextField(blank=True, default="") suggestion = models.TextField(blank=True, default="") status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN) evidence = models.JSONField(default=dict, blank=True) citations = models.JSONField(default=list, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: db_table = "ra_regulatory_issue" ordering = ["severity", "id"] indexes = [ models.Index(fields=["batch", "severity"], name="idx_ra_rr_issue_severity"), models.Index(fields=["batch", "category"], name="idx_ra_rr_issue_category"), ] def __str__(self) -> str: return self.title class RegulatoryArtifact(models.Model): """Stores regulatory review intermediate and exported artifacts.""" class ArtifactType(models.TextChoices): MARKDOWN = "markdown", "Markdown" EXCEL = "excel", "Excel" JSON = "json", "JSON" TEXT = "text", "文本" batch = models.ForeignKey( RegulatoryReviewBatch, on_delete=models.CASCADE, related_name="artifacts", ) artifact_type = models.CharField(max_length=20, choices=ArtifactType.choices) name = models.CharField(max_length=160) storage_path = models.CharField(max_length=500) content_hash = models.CharField(max_length=128, blank=True, default="") metadata = models.JSONField(default=dict, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: db_table = "ra_regulatory_artifact" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["batch", "artifact_type"], name="idx_ra_rr_artifact_type"), ] class RegulatoryNotificationRecord(models.Model): """Stores mock notification records for future Feishu integration.""" class Channel(models.TextChoices): MOCK = "mock", "模拟" FEISHU = "feishu", "飞书" class Status(models.TextChoices): PENDING = "pending", "待发送" SENT = "sent", "已发送" FAILED = "failed", "失败" batch = models.ForeignKey( RegulatoryReviewBatch, on_delete=models.CASCADE, related_name="notifications", ) channel = models.CharField(max_length=20, choices=Channel.choices, default=Channel.MOCK) target = models.CharField(max_length=160, blank=True, default="") payload = models.JSONField(default=dict, blank=True) status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) error_message = models.TextField(blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) sent_at = models.DateTimeField(null=True, blank=True) class Meta: db_table = "ra_regulatory_notification_record" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["batch", "status"], name="idx_ra_rr_notify_status"), ] class ApplicationFormFillArtifact(models.Model): """Stores auto-fill intermediate files and generated artifacts.""" class ArtifactType(models.TextChoices): TEMPLATE_COPY = "template_copy", "模板副本" FIELD_EXTRACT_RESULT = "field_extract_result", "字段抽取结果" MERGED_FIELDS = "merged_fields", "字段合并结果" TRACEABILITY = "traceability", "追溯清单" FILLED_TEMPLATE = "filled_template", "已填模板" NOTIFICATION_RECORD = "notification_record", "通知记录" class FileFormat(models.TextChoices): JSON = "json", "JSON" EXCEL = "excel", "Excel" DOCX = "docx", "DOCX" PDF = "pdf", "PDF" MARKDOWN = "markdown", "Markdown" batch = models.ForeignKey( ApplicationFormFillBatch, on_delete=models.CASCADE, related_name="artifacts", ) artifact_type = models.CharField(max_length=60, choices=ArtifactType.choices) file_format = models.CharField(max_length=20, choices=FileFormat.choices) name = models.CharField(max_length=160) file_name = models.CharField(max_length=255) storage_path = models.CharField(max_length=500) file_size = models.BigIntegerField(default=0) content_hash = models.CharField(max_length=128, blank=True, default="") metadata = models.JSONField(default=dict, blank=True) created_by_node = models.CharField(max_length=60, blank=True, default="") created_at = models.DateTimeField(auto_now_add=True) is_deleted = models.BooleanField(default=False) class Meta: db_table = "ra_application_form_fill_artifact" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["batch", "artifact_type"], name="idx_ra_aff_artifact_batch_type"), models.Index(fields=["file_format"], name="idx_ra_aff_artifact_format"), models.Index(fields=["created_at"], name="idx_ra_aff_artifact_created"), ] class ApplicationFormFillNotificationRecord(models.Model): """Stores mock/Feishu notification records for application-form auto-fill.""" class Channel(models.TextChoices): FEISHU_CLI = "feishu_cli", "飞书 CLI" FEISHU_API = "feishu_api", "飞书 API" MOCK = "mock", "模拟" class SendStatus(models.TextChoices): PENDING = "pending", "待发送" SUCCESS = "success", "成功" FAILED = "failed", "失败" batch = models.ForeignKey( ApplicationFormFillBatch, on_delete=models.CASCADE, related_name="notifications", ) recipient = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="application_form_fill_notifications", ) channel = models.CharField(max_length=30, choices=Channel.choices, default=Channel.MOCK) template_codes = models.JSONField(default=list, blank=True) export_ids = models.JSONField(default=list, blank=True) message_summary = models.TextField(blank=True, default="") send_status = models.CharField( max_length=20, choices=SendStatus.choices, default=SendStatus.PENDING, ) retry_count = models.PositiveIntegerField(default=0) external_message_id = models.CharField(max_length=120, blank=True, default="") error_message = models.TextField(blank=True, default="") sent_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_deleted = models.BooleanField(default=False) class Meta: db_table = "ra_application_form_fill_notification_record" ordering = ["-created_at", "-id"] indexes = [ models.Index(fields=["batch", "created_at"], name="idx_ra_aff_notify_batch"), 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}"