feat: add feishu notification data models
This commit is contained in:
@@ -126,6 +126,24 @@ SILICONFLOW_EMBEDDING_MODEL = os.environ.get(
|
|||||||
)
|
)
|
||||||
SILICONFLOW_EMBEDDING_DIMENSIONS = int(os.environ.get("SILICONFLOW_EMBEDDING_DIMENSIONS", "1024"))
|
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 = {
|
LOGGING = {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"disable_existing_loggers": False,
|
"disable_existing_loggers": False,
|
||||||
|
|||||||
74
review_agent/admin.py
Normal file
74
review_agent/admin.py
Normal 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",)
|
||||||
@@ -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"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -754,3 +754,202 @@ class ApplicationFormFillNotificationRecord(models.Model):
|
|||||||
models.Index(fields=["recipient", "send_status"], name="idx_ra_aff_notify_recipient"),
|
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"),
|
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}"
|
||||||
|
|||||||
104
tests/test_feishu_models.py
Normal file
104
tests/test_feishu_models.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user