# 飞书通知与问答接入详细设计 ## 文档信息 | 项目 | 内容 | | --- | --- | | 需求分析文档 | docs/1.需求分析/4.飞书通知与问答接入.md | | 功能设计文档 | docs/2.功能设计/4.飞书通知与问答接入.md | | 数据库设计文档 | docs/4.数据库设计/4.飞书通知与问答接入.md | | 所属模块 | 审核智能体 review_agent | | 设计日期 | 2026-06-07 | | 设计版本 | V1.0 | --- ## 一、实现目标 首期实现一个统一飞书通知能力,使自动汇总、法规核查、自动填表三个工作流在完成、部分成功或失败时,通过飞书官方智能体/应用机器人消息 API 向指定个人账号发送富文本私聊通知。通知失败不阻断主流程,发送结果落库并在批次详情页展示。 同时预留飞书私聊问答所需的用户映射、查询服务、权限过滤和问答日志模型,但不实现飞书事件订阅回调。 --- ## 二、推荐文件结构 | 文件 | 类型 | 责任 | | --- | --- | --- | | `review_agent/models.py` | 修改 | 新增 `FeishuUserMapping`、`WorkflowNotificationRecord`、`FeishuQuestionLog` | | `review_agent/admin.py` | 修改/新增 | 注册飞书用户映射和通知记录后台 | | `review_agent/notifications/__init__.py` | 新增 | 通知模块包 | | `review_agent/notifications/context.py` | 新增 | 定义统一通知上下文 dataclass | | `review_agent/notifications/recipient.py` | 新增 | 解析首期指定个人接收人;后续扩展为按系统用户映射解析 | | `review_agent/notifications/message_builder.py` | 新增 | 构造飞书富文本 payload 和摘要 | | `review_agent/notifications/feishu_token.py` | 新增 | 使用 App ID/App Secret 获取并缓存 tenant_access_token | | `review_agent/notifications/feishu_message_api.py` | 新增 | 调用飞书发送消息 API、处理响应解析 | | `review_agent/notifications/records.py` | 新增 | 判重和通知记录落库 | | `review_agent/notifications/dispatcher.py` | 新增 | 对外统一发送入口 | | `review_agent/notifications/workflow_adapters.py` | 新增 | 三个工作流批次到通知上下文的适配 | | `review_agent/feishu_questions/query.py` | 新增 | 后续问答预留:批次摘要查询 | | `review_agent/feishu_questions/permissions.py` | 新增 | 后续问答预留:权限过滤 | | `tests/test_feishu_notification.py` | 新增 | 飞书通知单元测试 | | `tests/test_feishu_question_reserved.py` | 新增 | 问答预留服务测试 | --- ## 三、数据结构设计 ### 3.1 NotificationContext ```python from dataclasses import dataclass, field from typing import Any @dataclass(frozen=True) class NotificationContext: workflow_type: str workflow_batch_id: int workflow_batch_no: str workflow_status: str title: str trigger_user_id: int trigger_username: str result_url: str summary_lines: list[str] = field(default_factory=list) next_action: str = "" metadata: dict[str, Any] = field(default_factory=dict) @property def dedupe_key(self) -> str: return f"{self.workflow_type}:{self.workflow_batch_id}:{self.workflow_status}" ``` ### 3.2 ResolvedFeishuTarget ```python from dataclasses import dataclass @dataclass(frozen=True) class ResolvedFeishuTarget: mapping_id: int | None display_name: str identifier_type: str identifier_value: str masked_identifier: str missing: bool = False ``` identifier_type 取值: | 值 | 说明 | | --- | --- | | open_id | 使用飞书 open_id | | user_id | 使用飞书 user_id | | mobile | 使用手机号,后续按发起人私聊时使用 | | missing | 未配置映射 | --- ## 四、模型详细设计 ### 4.1 FeishuUserMapping 字段见数据库设计。模型需提供方法: ```python 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", "" ``` `clean()` 校验: ```python def clean(self): if not (self.feishu_open_id or self.feishu_user_id or self.feishu_mobile): raise ValidationError("feishu_open_id、feishu_user_id、feishu_mobile 至少填写一个") ``` ### 4.2 WorkflowNotificationRecord 字段见数据库设计。建议方法: ```python @classmethod def already_sent(cls, dedupe_key: str) -> bool: return cls.objects.filter(dedupe_key=dedupe_key, send_status=cls.SendStatus.SUCCESS).exists() ``` 注意:若使用唯一约束限制 `dedupe_key`,重复触发时可以直接返回已有记录;若希望保留 skipped_duplicate 记录,则不能对 dedupe_key 做全局唯一,只能用查询判重。本项目需求是“只发一次”,更推荐保留唯一成功意图,重复触发返回已有记录或创建 skipped 记录需在实现计划中二选一。为了 SQLite 简化,首期建议不创建 skipped 记录,直接返回已有成功记录。 --- ## 五、核心服务详细设计 ### 5.1 workflow_adapters.py 职责:把不同批次对象转换为 `NotificationContext`。 函数: ```python def build_file_summary_context(batch: FileSummaryBatch) -> NotificationContext: ... def build_regulatory_review_context(batch: RegulatoryReviewBatch) -> NotificationContext: ... def build_application_form_fill_context(batch: ApplicationFormFillBatch) -> NotificationContext: ... ``` 自动汇总摘要: | 字段 | 计算方式 | | --- | --- | | 文件总数 | `batch.items.count()` | | 成功解析数 | 解析状态为 success 的 item 数 | | 异常数 | failed、skipped、unsupported 等状态数量 | | 导出文件数 | `ExportedSummaryFile` 中 workflow_type=file_summary 或 batch 关联文件数 | 法规核查摘要: | 字段 | 计算方式 | | --- | --- | | 风险总数 | `batch.issues.count()` | | 阻断项 | severity=blocking | | 高风险 | severity=high | | 中风险 | severity=medium | 自动填表摘要: | 字段 | 计算方式 | | --- | --- | | 模板数 | `len(batch.selected_templates)` | | 导出文件数 | 对应 `ExportedSummaryFile` 数量 | | 冲突字段数 | `len(batch.conflict_summary or [])` | | 失败原因 | `batch.error_message` 或节点错误摘要 | ### 5.2 recipient.py 职责:首期根据环境变量解析指定个人接收人;后续可扩展为根据系统用户解析飞书目标。 伪代码: ```python def resolve_feishu_target(user: User) -> ResolvedFeishuTarget: if settings.FEISHU_DEFAULT_USER_OPEN_ID: return ResolvedFeishuTarget( mapping_id=None, display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"), identifier_type="open_id", identifier_value=settings.FEISHU_DEFAULT_USER_OPEN_ID, masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_OPEN_ID), missing=False, ) if settings.FEISHU_DEFAULT_USER_ID: return ResolvedFeishuTarget( mapping_id=None, display_name=getattr(settings, "FEISHU_DEFAULT_TARGET_NAME", "指定个人账号"), identifier_type="user_id", identifier_value=settings.FEISHU_DEFAULT_USER_ID, masked_identifier=mask_identifier(settings.FEISHU_DEFAULT_USER_ID), missing=False, ) return ResolvedFeishuTarget( mapping_id=None, display_name=user.get_username(), identifier_type="missing", identifier_value="", masked_identifier="", missing=True, ) def resolve_feishu_target_by_user_mapping(user: User) -> ResolvedFeishuTarget: mapping = ( FeishuUserMapping.objects .filter(system_user=user, is_active=True) .first() ) if mapping is None: return ResolvedFeishuTarget( mapping_id=None, display_name=user.get_username(), identifier_type="missing", identifier_value="", masked_identifier="", missing=True, ) identifier_type, identifier_value = mapping.preferred_identifier() return ResolvedFeishuTarget( mapping_id=mapping.pk, display_name=mapping.feishu_display_name or user.get_username(), identifier_type=identifier_type, identifier_value=identifier_value, masked_identifier=mask_identifier(identifier_value), missing=identifier_type == "missing", ) ``` 脱敏规则: | 类型 | 规则 | | --- | --- | | mobile | 保留前三位和后四位,如 `138****1234` | | open_id/user_id | 保留前 6 位和后 4 位 | | missing | 空字符串 | 首期调度器使用 `resolve_feishu_target()`。`resolve_feishu_target_by_user_mapping()` 作为后续“按发起人私聊”能力预留。 ### 5.3 message_builder.py 职责:构造富文本 payload 和入库摘要。 函数: ```python def build_feishu_post_message( context: NotificationContext, target: ResolvedFeishuTarget, ) -> dict: ... def build_message_summary( context: NotificationContext, target: ResolvedFeishuTarget, ) -> str: ... ``` 富文本规则: | 场景 | 规则 | | --- | --- | | 有映射 | 加入 `at` 标签 | | 无映射 | 不加入 `at` 标签,增加映射缺失提示 | | 失败状态 | 标题和下一步动作突出失败原因摘要 | | 摘要过长 | 每条摘要最多 120 字,总摘要最多 800 字 | | 链接 | 使用本地地址拼接,后续再切换域名配置 | ### 5.4 feishu_token.py 职责:使用 App ID/App Secret 获取并缓存 `tenant_access_token`。 函数: ```python def get_tenant_access_token() -> FeishuTokenResult: ... def refresh_tenant_access_token() -> FeishuTokenResult: ... ``` 结果结构: ```python @dataclass(frozen=True) class FeishuTokenResult: ok: bool tenant_access_token: str expire_seconds: int code: str message: str ``` 处理规则: | 场景 | 处理 | | --- | --- | | App ID/App Secret 缺失 | 返回 failed,错误码 config_missing | | 缓存 token 未过期 | 直接返回缓存 token | | token 过期或不存在 | 调用飞书 token API 重新获取 | | token API 返回失败 | 返回 failed,记录 code/message | | HTTP 超时 | 返回 failed,错误码 timeout | ### 5.5 feishu_message_api.py 职责:调用飞书发送消息 API。 函数: ```python def send_personal_message( *, tenant_access_token: str, receive_id_type: str, receive_id: str, payload: dict, ) -> FeishuMessageApiResult: ... ``` 结果结构: ```python @dataclass(frozen=True) class FeishuMessageApiResult: ok: bool status_code: int | None code: str message: str duration_ms: int message_id: str = "" ``` 异常处理: | 异常 | 处理 | | --- | --- | | 指定接收人缺失 | 返回 failed,错误码 recipient_missing | | tenant_access_token 缺失 | 返回 failed,错误码 token_missing | | HTTP 超时 | 返回 failed,错误码 timeout | | 非 2xx | 返回 failed,记录 status_code | | 飞书返回 code 非 0 | 返回 failed,记录 code/message | | token 失效 | 刷新 token 后允许同步重试一次消息 API | ### 5.6 records.py 职责:判重和落库。 流程: ```text 输入 NotificationContext -> 查询 dedupe_key 是否已有 success -> 若有,返回已有记录,不发送 -> 若未启用真实飞书,创建 disabled/mock 记录 -> 若发送成功,创建 success 记录 -> 若发送失败,创建 failed 记录 ``` 字段写入规则: | 字段 | 来源 | | --- | --- | | workflow_type | context.workflow_type | | workflow_batch_id | context.workflow_batch_id | | workflow_batch_no | context.workflow_batch_no | | workflow_status | context.workflow_status | | dedupe_key | context.dedupe_key | | trigger_user_id | context.trigger_user_id | | feishu_mapping_id | target.mapping_id | | at_identifier_type | target.identifier_type | | at_identifier_masked | target.masked_identifier | | message_summary | `build_message_summary()` | ### 5.7 dispatcher.py 对外入口: ```python def dispatch_workflow_notification(context: NotificationContext) -> WorkflowNotificationRecord: if WorkflowNotificationRecord.already_sent(context.dedupe_key): return WorkflowNotificationRecord.objects.get( dedupe_key=context.dedupe_key, send_status=WorkflowNotificationRecord.SendStatus.SUCCESS, ) user = User.objects.get(pk=context.trigger_user_id) target = resolve_feishu_target(user) message = build_feishu_post_message(context, target) summary = build_message_summary(context, target) if not settings.FEISHU_NOTIFY_ENABLED: return create_disabled_record(context, target, summary) token_result = get_tenant_access_token() if not token_result.ok: return create_failed_record(context, target, summary, token_result) result = send_personal_message( tenant_access_token=token_result.tenant_access_token, receive_id_type=target.identifier_type, receive_id=target.identifier_value, payload=message, ) if result.ok: return create_success_record(context, target, summary, result) return create_failed_record(context, target, summary, result) ``` --- ## 六、工作流接入点 | 工作流 | 推荐接入位置 | | --- | --- | | 自动汇总 | 文件汇总批次状态写为 success/partial_success/failed 后 | | 法规核查 | 报告导出和风险项保存后;替换或并行现有 `create_mock_notifications` | | 自动填表 | `notify` 节点中替换或扩展现有 `notify_completion` | 接入原则: | 原则 | 说明 | | --- | --- | | 通知异常捕获 | 工作流调用通知服务时捕获异常并记录 non_blocking_errors | | 不回滚业务结果 | 通知失败不修改业务批次成功状态 | | 单点适配 | 工作流只负责生成或传入批次,摘要由 adapter 负责 | --- ## 七、批次详情展示设计 ### 7.1 后端上下文 为批次详情页提供: ```python def get_notification_records(workflow_type: str, batch_id: int) -> QuerySet: return WorkflowNotificationRecord.objects.filter( workflow_type=workflow_type, workflow_batch_id=batch_id, ).order_by("-created_at") ``` ### 7.2 页面展示规则 | 状态 | 展示 | | --- | --- | | success | “飞书通知已发送”,展示 sent_at | | failed | “飞书通知失败”,展示 error_message | | disabled | “飞书通知未启用” | | 无记录 | “暂无通知记录” | 三个工作流结果页可复用同一 partial 模板或上下文字段。 --- ## 八、问答预留详细设计 ### 8.1 批次摘要查询服务 预留函数: ```python def query_batch_summary( user: User, *, workflow_type: str | None = None, batch_no: str | None = None, latest: bool = False, ) -> dict: ... ``` 权限规则: | 用户 | 可查范围 | | --- | --- | | 管理员 | 全部批次 | | 普通用户 | `batch.user == user` 的批次 | | 未绑定用户 | 不可查 | 查询对象: | 类型 | 说明 | | --- | --- | | 明确批次号 | 精确匹配 batch_no | | 最近/最新 | 在有权限范围内按 created_at/finished_at 倒序取第一条 | | 工作流类型 | file_summary、regulatory_review、application_form_fill | ### 8.2 问答日志服务 预留函数: ```python def record_feishu_question_log( *, user: User | None, mapping: FeishuUserMapping | None, source_type: str, question_text: str, intent: str, query_object: dict, answer_summary: str, permission_result: str, status: str, error_message: str = "", ) -> FeishuQuestionLog: ... ``` 首期不需要接飞书事件,但测试可直接调用该服务,确认日志字段与权限规则可用。 --- ## 九、测试设计 ### 9.1 单元测试 | 测试文件 | 用例 | | --- | --- | | `tests/test_feishu_notification.py` | tenant_access_token 获取和缓存 | | `tests/test_feishu_notification.py` | 指定个人接收人优先级 open_id > user_id | | `tests/test_feishu_notification.py` | 指定接收人缺失时写 failed 记录 | | `tests/test_feishu_notification.py` | 真实通知关闭时写 disabled/mock 记录 | | `tests/test_feishu_notification.py` | 消息 API 成功写 success 记录 | | `tests/test_feishu_notification.py` | token 获取失败写 failed 记录 | | `tests/test_feishu_notification.py` | 消息 API 超时写 failed 记录 | | `tests/test_feishu_notification.py` | 同一 dedupe_key 不重复发送 | | `tests/test_feishu_question_reserved.py` | 管理员可查询全部批次摘要 | | `tests/test_feishu_question_reserved.py` | 普通用户只能查询自己的批次 | | `tests/test_feishu_question_reserved.py` | 问答日志不保存完整回答正文 | ### 9.2 集成测试 | 场景 | 验证 | | --- | --- | | 自动汇总完成 | 生成通知上下文并写记录 | | 法规核查完成 | 风险摘要正确 | | 自动填表完成 | 导出和冲突摘要正确 | | 批次详情页 | 展示通知状态和失败原因 | ### 9.3 外部飞书测试 真实飞书 API 测试不进入默认 CI。建议提供手动命令或 Django management command: ```text python manage.py send_test_feishu_notification --username owner ``` 该命令只在本地配置 `FEISHU_NOTIFY_ENABLED=true`、`FEISHU_APP_ID`、`FEISHU_APP_SECRET`、`FEISHU_DEFAULT_USER_OPEN_ID` 或 `FEISHU_DEFAULT_USER_ID` 后使用。 --- ## 十、异常处理 | 异常 | 处理 | | --- | --- | | 指定接收人缺失 | 不发送真实消息,记录 recipient_missing | | App ID/App Secret 未配置 | 写 failed 或 disabled 记录,不发送 | | tenant_access_token 获取失败 | 写 failed,记录 token API 错误 | | 指定接收人 open_id/user_id 未配置 | 写 failed,错误码 recipient_missing | | HTTP 超时 | 写 failed,错误码 timeout | | 飞书返回错误 | 写 failed,记录 code/message | | 通知记录唯一冲突 | 查询已有记录并返回,不重复发送 | | 批次链接生成失败 | 发送无链接摘要,记录 warning 到 message_summary | --- ## 十一、日志与安全 | 项 | 要求 | | --- | --- | | 日志脱敏 | 不打印 App Secret、tenant_access_token、完整手机号 | | 入库脱敏 | 通知记录只保存脱敏接收人标识 | | payload | 不保存完整富文本 payload | | 错误信息 | 保存飞书错误摘要,避免保存敏感请求头 | | 问答日志 | 保存问题、意图、对象和回答摘要,不保存完整回答 | --- ## 十二、实施顺序建议 | 顺序 | 内容 | | --- | --- | | 1 | 新增模型、迁移和 Admin | | 2 | 实现用户映射解析和脱敏 | | 3 | 实现飞书富文本构造 | | 4 | 实现 tenant_access_token 获取与缓存 | | 5 | 实现飞书消息 API 发送客户端 | | 6 | 实现通知记录判重和落库 | | 7 | 实现三个工作流 adapter | | 8 | 接入三个工作流完成节点 | | 9 | 批次详情页展示通知状态 | | 10 | 实现问答预留查询服务和日志服务 | | 11 | 补齐单元测试和集成测试 |