# 审批与消息模块架构文档 **版本**: 1.0.0 **更新时间**: 2026-02-28 **状态**: 设计中 --- ## 📋 概述 系统采用**模块化设计**,审批模块和消息模块是两个独立但可联合使用的核心模块。 ### 设计原则 1. **模块独立**:各模块可独立部署和使用 2. **松耦合**:模块间通过接口通信,不直接依赖 3. **可扩展**:支持新增业务场景(如请假、采购、报销等) 4. **统一协议**:所有业务场景遵循相同的审批和消息协议 --- ## 🏗️ 模块架构 ``` ┌─────────────────────────────────────────────────────────────┐ │ 业务场景层 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │ 出差审批 │ │ 请假审批 │ │ 采购审批 │ │ 费用报销 │ │ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 审批引擎层(ApprovalService) │ │ - 流程管理 - 节点管理 - 订单管理 - 记录管理 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 消息服务层(FeishuNotificationService) │ │ - 消息发送 - 卡片构建 - 消息更新 - 日志记录 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 飞书平台 │ │ - 消息推送 - 卡片交互 - 回调处理 │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 🔧 审批模块(Approval Module) ### 核心模型 | 模型 | 说明 | 关键字段 | |------|------|----------| | `ApprovalFlow` | 审批流程模板 | business_type, name, status | | `ApprovalNode` | 审批节点 | type, approvers, order | | `ApprovalOrder` | 审批订单实例 | business_type, business_id, form_data, current_nodes, status | | `ApprovalRecord` | 审批操作记录 | order_id, node_id, approver_id, action | ### 节点类型 ```php const TYPE_APPROVER = 'approver'; // 单人审批:1人通过即可 const TYPE_AND_APPROVE = 'and_approve'; // 会签:所有人通过 const TYPE_OR_APPROVE = 'or_approve'; // 或签:1人通过即可 const TYPE_NOTIFY = 'notify'; // 通知节点:无需审批 ``` ### 审批状态流转 ``` draft(草稿) ↓ submit pending(审批中) ↓ ├─→ approved(已通过) ├─→ rejected(已拒绝) └─→ cancelled(已撤销) ``` ### 核心方法 | 方法 | 说明 | |------|------| | `submit()` | 提交审批 | | `approve()` | 审批通过 | | `reject()` | 审批拒绝 | | `cancel()` | 撤销申请 | | `isNodeCompleted()` | 判断节点是否完成 | ### 扩展新业务场景 **步骤:** 1. **创建业务模型**(如 `LeaveRequest`) 2. **定义流程类型**(`business_type = 'leave'`) 3. **实现审批逻辑** - 调用 `ApprovalService::submit()` 创建审批订单 - 在 `form_data` 中存储业务数据 4. **实现业务逻辑** - 审批通过后的业务处理 - 审批拒绝后的业务处理 **示例:** ```php // 请假审批 $approvalOrder = $approvalService->submit([ 'business_type' => 'leave', 'business_id' => $leaveRequest->id, 'title' => '请假申请 - ' . $user->name, 'applicant_id' => $user->id, 'flow_id' => $flow->id, 'form_data' => [ 'reason' => $leaveRequest->reason, 'start_date' => $leaveRequest->start_date, 'end_date' => $leaveRequest->end_date, 'approvers' => $selectedApprovers, 'cc_users' => $selectedCcUsers, ], ]); ``` --- ## 📨 消息模块(Notification Module) ### 核心模型 | 模型 | 说明 | 关键字段 | |------|------|----------| | `FeishuMessage` | 消息映射表 | order_id, node_id, user_id, message_id, status | | `FeishuMessageLog` | 消息日志表 | action, order_id, message_id, status, request_data, response_data | ### 消息类型 | 类型 | 场景 | 卡片样式 | |------|------|----------| | 审批通知 | 发送给审批人 | 带通过/拒绝按钮 | | 系统通知 | 发送给抄送人 | 带查看详情按钮 | | 文本通知 | 自审批场景 | 纯文本,无按钮 | | 结果通知 | 审批结果通知 | 显示操作结果 | ### 核心方法 | 方法 | 说明 | |------|------| | `sendApprovalNotification()` | 发送审批通知(带操作按钮) | | `sendSystemNotification()` | 发送系统通知(只读) | | `updateMessageCards()` | 更新消息卡片(移除按钮) | | `buildApprovalCard()` | 构建审批卡片 | | `buildSystemCard()` | 构建系统通知卡片 | ### 消息生命周期 ``` 1. 发送消息 - 调用飞书 API - 获取 message_id - 保存映射到 feishu_message - 记录日志到 feishu_message_log 2. 用户操作 - 点击卡片按钮 - 飞书回调处理 - 执行审批逻辑 3. 更新消息(或签/单人场景) - 节点完成后触发 - 更新其他审批人的卡片 - 移除操作按钮 - 记录日志 ``` ### 扩展新消息类型 **步骤:** 1. **定义卡片构建方法** ```php private function buildXXXCard(array $params): array { // 返回卡片数据结构 } ``` 2. **添加发送方法** ```php public function sendXXXNotification(array $params): bool { // 构建卡片 $card = $this->buildXXXCard($params); // 发送消息 return $this->sendMessage($feishuUserId, $card); } ``` 3. **添加更新方法**(如需要) ```php public function updateXXXCards(...): bool { // 更新逻辑 } ``` --- ## 🔗 模块协作 ### 协作流程 ``` ┌─────────────────────────────────────────────────────────────┐ │ 业务场景(如出差申请) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 调用 ApprovalService::submit() │ │ - 创建审批订单 │ │ - 返回 approval_order │ └──────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 调用 FeishuNotificationService │ │ - 发送消息给审批人 │ │ - 保存消息映射 │ │ - 记录日志 │ └──────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 用户点击消息卡片操作 │ │ - 飞书回调到后端 │ │ - 调用 ApprovalService::approve() │ └──────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 审批完成后 │ │ - 判断是否需要更新其他人的消息 │ │ - 或签/单人:调用 updateMessageCards │ │ - 会签:等待其他人审批 │ └──────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 流程结束 │ │ - 通知申请人结果 │ └──────────────────────────────────────┘ ``` ### 接口定义 **审批模块 → 消息模块** ```php // 发送审批通知 $notificationService->sendApprovalNotification([ 'user_id' => $approverId, 'applicant_id' => $applicantId, 'title' => '新的审批待办', 'content' => '...', 'url' => $detailUrl, 'order_id' => $orderId, 'node_id' => $nodeId, ]); // 更新消息卡片(或签/单人场景) $notificationService->updateMessageCards( $orderId, $nodeId, $excludeUserId, 'approved', $operatorName ); ``` --- ## 🚧 扩展指南 ### 添加新的审批业务场景 **示例:添加采购审批** 1. **创建数据库表** ```sql CREATE TABLE purchase_request ( id bigint unsigned PRIMARY KEY, user_id bigint unsigned NOT NULL, reason varchar(500), amount decimal(10,2), status enum('draft', 'pending', 'approved', 'rejected'), approval_order_id bigint unsigned, ... ); ``` 2. **创建模型** ```php class PurchaseRequest extends Model { public function approvalOrder() { return $this->belongsTo(ApprovalOrder::class); } } ``` 3. **创建服务** ```php class PurchaseService { private $approvalService; public function submit(array $data): PurchaseRequest { return DB::transaction(function () use ($data) { // 创建采购申请 $purchase = PurchaseRequest::create($data); // 创建审批订单 $approvalOrder = $this->approvalService->submit([ 'business_type' => 'purchase', 'business_id' => $purchase->id, 'title' => '采购申请 - ' . $data['item_name'], 'applicant_id' => $data['user_id'], 'flow_id' => $this->getFlowId(), 'form_data' => [ 'item_name' => $data['item_name'], 'amount' => $data['amount'], 'approvers' => $data['approvers'], 'cc_users' => $data['cc_users'], ], ]); // 关联审批订单 $purchase->approval_order_id = $approvalOrder->id; $purchase->status = 'pending'; $purchase->save(); // 发送通知 $this->sendNotifications($purchase, $approvalOrder); return $purchase; }); } } ``` 4. **创建控制器** ```php class PurchaseController { public function submit(Request $request): Response { $result = $this->service->submit($request->post()); return $this->success($result); } } ``` ### 添加新的消息类型 **示例:添加催办通知** ```php class FeishuNotificationService { /** * 发送催办通知 */ public function sendReminderNotification(array $params): bool { $userId = $params['user_id']; $card = $this->buildReminderCard($params); // 保存消息映射(如需要) if (isset($params['order_id']) && isset($params['node_id'])) { \app\model\FeishuMessage::saveMessage( $params['order_id'], $params['node_id'], $userId, $messageId ); } return $this->sendMessage($feishuUserId, $card); } private function buildReminderCard(array $params): array { return [ 'header' => [ 'title' => ['content' => '⏰ 审批催办', 'tag' => 'plain_text'], 'template' => 'orange', ], 'elements' => [ [ 'tag' => 'div', 'text' => [ 'content' => $params['content'], 'tag' => 'lark_md', ], ], [ 'tag' => 'action', 'actions' => [ [ 'tag' => 'button', 'text' => ['content' => '立即处理', 'tag' => 'plain_text'], 'type' => 'default', 'url' => $params['url'], ], ], ], ], ]; } } ``` --- ## 📊 数据流向图 ### 完整审批流程数据流 ``` 用户提交申请 ↓ ┌───────────────────────────────────────────────────────┐ │ 1. 业务表(business_trip) │ │ - 创建业务记录 │ │ - status = 'draft' │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 2. 审批订单(approval_order) │ │ - business_type = 'business_trip' │ │ - business_id = 业务记录ID │ │ - form_data = { │ │ approvers: [用户选择的审批人], │ │ cc_users: [用户选择的抄送人], │ │ ...业务数据 │ │ } │ │ - current_nodes = [第一个节点ID] │ │ - status = 'pending' │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 3. 消息映射(feishu_message) │ │ - order_id + node_id + user_id → message_id │ │ - 每个审批人一条记录 │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 4. 飞书平台 │ │ - 发送卡片消息给审批人 │ └───────────────────────────────────────────────────────┘ ↓ 审批人操作 ↓ ┌───────────────────────────────────────────────────────┐ │ 5. 审批记录(approval_record) │ │ - order_id, node_id, approver_id │ │ - action = 'approve' 或 'reject' │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 6. 节点完成判断 │ │ - 检查 form_data.approvers(实际选择的审批人) │ │ - 节点类型决定完成条件 │ │ - 或签/单人:立即完成,触发消息更新 │ │ - 会签:等待所有人 │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 7. 消息更新(或签/单人场景) │ │ - 更新其他审批人的消息卡片 │ │ - 移除操作按钮 │ │ - 标记消息状态 = 'updated' │ └───────────────────────────────────────────────────────┘ ↓ ┌───────────────────────────────────────────────────────┐ │ 8. 流程流转 │ │ - 移动到下一节点 或 完成流程 │ │ - 通知下一节点审批人 / 通知申请人结果 │ └───────────────────────────────────────────────────────┘ ``` --- ## 🔍 调试与监控 ### 消息日志查询 ```bash # 查看最近50条日志 curl "http://localhost:8787/debug/message-logs?debug=1&limit=50" # 查看指定订单的日志 curl "http://localhost:8787/debug/message-logs?debug=1&order_id=123" # 直接查询数据库 docker exec 1Panel-mysql-409s mysql -uroot -pmysql_xEsdPa xserver -e " SELECT * FROM feishu_message_log WHERE order_id = 123 ORDER BY created_at DESC; " ``` ### 日志字段说明 | 字段 | 说明 | |------|------| | `action` | send(发送)、update(更新)、update_card(更新卡片) | | `status` | success(成功)、failed(失败) | | `request_data` | 发送的数据(JSON) | | `response_data` | 飞书返回的数据(JSON) | | `error_message` | 错误信息(如有) | --- ## 📝 注意事项 1. **审批人来源**:始终从 `form_data.approvers` 获取,不是从 `node.approvers` 2. **消息更新**:仅在或签/单人审批完成时触发 3. **防止重复**:审批前检查是否已操作过 4. **事务处理**:业务逻辑和审批创建在同一事务中 5. **异常处理**:记录所有失败日志,方便排查问题 --- ## 🏢 多公司多飞书应用架构 ### 架构概述 在多租户场景下,每个公司可以有独立的飞书应用配置: - 不同的 `app_id` - 不同的 `app_secret` - 不同的 bot 身份 ### 核心约束 **飞书API限制**:只有发送消息的bot才能更新该消息。 这意味着: - 公司A的bot发送的消息,只能由公司A的bot更新 - 公司B的bot无法更新公司A发送的消息 - 在跨公司审批流程中,需要追踪每条消息的发送者 ### 数据模型设计 #### feishu_message 表结构 | 字段 | 类型 | 说明 | |------|------|------| | `id` | bigint | 主键 | | `order_id` | bigint | 审批订单ID | | `node_id` | bigint | 审批节点ID | | `user_id` | bigint | 接收消息的用户ID | | `company_id` | bigint | **发送消息的公司ID** | | `feishu_app_id` | int | **发送消息的飞书应用ID** | | `message_id` | varchar | 飞书消息ID | | `msg_token` | varchar | 消息token(用于更新) | | `status` | varchar | 消息状态:active/updated/deleted | ### 发送消息流程 ```php // FeishuNotificationService::sendApprovalNotification() public function sendApprovalNotification(array $params): bool { $userId = $params['user_id']; $orderId = $params['order_id']; $nodeId = $params['node_id']; // 1. 获取用户信息(包含公司ID) $user = User::findOrFail($userId); // 2. 发送飞书消息 $response = $this->sendMessage($user->feishu_user_id, $card); $messageId = $response['data']['message_id']; // 3. 保存消息映射(包含公司ID和飞书应用ID) \app\model\FeishuMessage::saveMessage( $orderId, $nodeId, $userId, $messageId, null, $user->company_id, // ← 记录公司ID $this->appId // ← 记录飞书应用ID ); return true; } ``` ### 更新消息流程 ```php // FeishuNotificationService::updateMessageCards() public function updateMessageCards( int $orderId, int $nodeId, ?int $excludeUserId = null, string $result = 'approved', string $operatorName = '' ): bool { // 1. 获取该订单+节点的所有消息 $messages = \app\model\FeishuMessage::getMessagesByOrderAndNode($orderId, $nodeId); foreach ($messages as $message) { // 2. 获取消息发送时的配置 $companyId = $message->company_id; $feishuAppId = $message->feishu_app_id; if (!$companyId || !$feishuAppId) { error_log("❌ 消息记录缺少 company_id 或 feishu_app_id"); continue; } // 3. 临时切换到该消息对应的飞书应用配置 $originalCompanyId = $this->companyId; $originalAppId = $this->appId; $this->companyId = $companyId; $this->appId = $feishuAppId; try { // 4. 使用正确的飞书应用更新消息 $response = HttpClient::httpPatchJson( "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}", ['msg_type' => 'interactive', 'content' => $contentJson], ['content'], ['Authorization: Bearer ' . $this->getAccessToken()] ); error_log("✅ 更新消息成功: {$message->message_id}"); } catch (\Exception $e) { error_log("❌ 更新消息失败: {$e->getMessage()}"); } finally { // 5. 恢复原始配置 $this->companyId = $originalCompanyId; $this->appId = $originalAppId; } } return true; } ``` ### 跨公司审批场景示例 **场景**:公司A的员工发起审批,审批人包括公司A和公司B的员工 ``` ┌─────────────────────────────────────────────────────────────┐ │ 审批订单创建 │ │ - 申请人:公司A-用户1 │ │ - 审批人:[公司A-用户2, 公司B-用户1] │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 发送审批通知 │ │ - 公司A-用户2:使用公司A的飞书应用发送 │ │ - 公司B-用户1:使用公司B的飞书应用发送 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 公司A-用户2 点击"拒绝" │ │ - 触发 ApprovalService::reject() │ │ - 订单状态变为 rejected │ │ - 需要更新公司B-用户1的消息卡片 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ updateMessageCards() 执行 │ │ - 查询 feishu_message 表 │ │ - 公司B-用户1的消息记录: │ │ · company_id = 公司B的ID │ │ · feishu_app_id = 公司B的飞书应用ID │ │ - 切换到公司B的飞书应用配置 │ │ - 调用 PATCH /messages/{id} 更新消息 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ✅ 成功 ``` ### 关键实现要点 #### 1. FeishuNotificationService 支持动态切换配置 ```php class FeishuNotificationService { private int $companyId; private string $appId; private string $appSecret; // 支持运行时切换配置 public function switchCompany(int $companyId): void { $this->companyId = $companyId; $feishuConfig = $this->getFeishuConfig($companyId); $this->appId = $feishuConfig['app_id']; $this->appSecret = $feishuConfig['app_secret']; } private function getAccessToken(): string { $cacheKey = "feishu:access_token:{$this->companyId}"; // 使用 company_id 维度的缓存key // ... } } ``` #### 2. HttpClient 支持预编码JSON字段 ```php // httpPatchJson 方法支持某些字段预编码 HttpClient::httpPatchJson( $url, [ 'msg_type' => 'interactive', 'content' => $preEncodedJson, // 已编码的JSON字符串 ], ['content'], // 声明 content 字段需要预编码 $headers ); ``` #### 3. 数据完整性 ```sql -- 确保所有消息都有 company_id 和 feishu_app_id UPDATE feishu_message fm JOIN user u ON fm.user_id = u.id SET fm.company_id = u.company_id, fm.feishu_app_id = ( SELECT id FROM feishu_config WHERE company_id = u.company_id LIMIT 1 ) WHERE fm.company_id IS NULL; ``` ### 常见问题 #### Q1: 为什么会出现 "bot or user is not the message's sender" 错误? **A**: 使用了错误的飞书应用配置去更新消息。确保: 1. `feishu_message` 表正确记录了 `company_id` 和 `feishu_app_id` 2. 更新消息时使用记录中的配置,而不是当前操作者的配置 #### Q2: 如何调试多公司消息更新问题? **A**: 查看日志中的关键信息: ```bash docker logs x-server-backend -f | grep -E "(company_id|feishu_app_id|更新消息)" ``` #### Q3: 历史数据缺少 company_id 怎么办? **A**: 根据用户信息回填: ```sql UPDATE feishu_message fm JOIN user u ON fm.user_id = u.id SET fm.company_id = u.company_id; ``` ### 相关问题文档 详见 `docs/PROJECT_ISSUES.md` 第31条:多公司多飞书应用消息卡片更新失败 --- ## 📚 相关文档 - [数据库结构文档](./DATABASE_SCHEMA.md) - [项目文档](./PROJECT.md) - [问题汇总](./PROJECT_ISSUES.md) --- **维护人**: 开发团队 **最后更新**: 2026-03-01