feat: add feishu notification data models

This commit is contained in:
2026-06-07 22:03:05 +08:00
parent 003ff59268
commit da81ce24d0
5 changed files with 747 additions and 0 deletions

74
review_agent/admin.py Normal file
View File

@@ -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",)

View File

@@ -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"
),
),
]

View File

@@ -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}"