From b90f901fd9e419482d9016c3612cc54d2e2fbcfa Mon Sep 17 00:00:00 2001 From: root Date: Sun, 1 Mar 2026 12:29:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E5=85=AC?= =?UTF-8?q?=E5=8F=B8=E5=A4=9A=E9=A3=9E=E4=B9=A6=E5=BA=94=E7=94=A8=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=8D=A1=E7=89=87=E6=9B=B4=E6=96=B0=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feishu_message 表新增 company_id 和 feishu_app_id 字段追踪消息发送者 - 修改 FeishuMessage::saveMessage() 支持保存公司和飞书应用信息 - 修改 FeishuNotificationService 使用原始发送者的飞书应用配置更新消息 - 新增 HttpClient::httpPatchJson() 方法支持 PATCH 请求 - 文档更新:PROJECT_ISSUES.md 添加问题31,APPROVAL_NOTIFICATION_ARCHITECTURE.md 添加多公司架构说明 Co-Authored-By: Claude Opus 4.6 --- backend/app/model/FeishuMessage.php | 8 +- .../app/service/FeishuNotificationService.php | 143 +++++----- backend/app/util/HttpClient.php | 71 +++++ docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md | 253 +++++++++++++++++- docs/PROJECT_ISSUES.md | 189 +++++++++++++ 5 files changed, 586 insertions(+), 78 deletions(-) diff --git a/backend/app/model/FeishuMessage.php b/backend/app/model/FeishuMessage.php index 7cf98fd..cb00ada 100644 --- a/backend/app/model/FeishuMessage.php +++ b/backend/app/model/FeishuMessage.php @@ -15,6 +15,8 @@ class FeishuMessage extends Model 'order_id', 'node_id', 'user_id', + 'company_id', + 'feishu_app_id', 'message_id', 'msg_token', 'status', @@ -44,7 +46,9 @@ class FeishuMessage extends Model int $nodeId, int $userId, string $messageId, - ?string $msgToken = null + ?string $msgToken = null, + ?int $companyId = null, + ?int $feishuAppId = null ): self { return self::updateOrCreate( [ @@ -55,6 +59,8 @@ class FeishuMessage extends Model [ 'message_id' => $messageId, 'msg_token' => $msgToken, + 'company_id' => $companyId, + 'feishu_app_id' => $feishuAppId, 'status' => self::STATUS_ACTIVE, ] ); diff --git a/backend/app/service/FeishuNotificationService.php b/backend/app/service/FeishuNotificationService.php index b016316..71085a4 100644 --- a/backend/app/service/FeishuNotificationService.php +++ b/backend/app/service/FeishuNotificationService.php @@ -163,9 +163,17 @@ class FeishuNotificationService $messageId = $this->sendMessage($feishuUserId, $card, $orderId, $nodeId, $userId); if ($messageId && $orderId && $nodeId) { - // 保存消息映射 - \app\model\FeishuMessage::saveMessage($orderId, $nodeId, $userId, $messageId); - error_log("💾 保存消息映射: order_id={$orderId}, node_id={$nodeId}, user_id={$userId}, message_id={$messageId}"); + // 保存消息映射(包含公司ID和飞书应用ID) + \app\model\FeishuMessage::saveMessage( + $orderId, + $nodeId, + $userId, + $messageId, + null, + $user->company_id, + $this->appId + ); + error_log("💾 保存消息映射: order_id={$orderId}, node_id={$nodeId}, user_id={$userId}, company_id={$user->company_id}, feishu_app_id={$this->appId}, message_id={$messageId}"); } return $messageId !== false; @@ -662,6 +670,17 @@ class FeishuNotificationService * @param string $operatorName 操作人姓名 * @return bool */ + /** + * 更新其他审批人的消息卡片 + * 多公司多飞书应用场景:使用每个消息对应的原始飞书应用去更新 + * + * @param int $orderId 订单ID + * @param int $nodeId 节点ID + * @param int $excludeUserId 排除的用户ID(已经操作过的用户) + * @param string $status 审批状态 (approved/rejected) + * @param string $operatorName 操作人姓名 + * @return bool + */ public function updateMessageCards(int $orderId, int $nodeId, int $excludeUserId, string $status, string $operatorName): bool { try { @@ -677,27 +696,26 @@ class FeishuNotificationService return true; } - // 获取订单申请人信息,从用户中获取公司ID - $order = \app\model\ApprovalOrder::find($orderId); - $companyId = null; - if ($order) { - $applicant = \app\model\User::find($order->applicant_id); - if ($applicant && !empty($applicant->company_id)) { - $companyId = $applicant->company_id; - } - } - - // 如果获取到公司ID,设置飞书应用配置 - if ($companyId) { - $this->setAppByUser($excludeUserId, $companyId); - } - - $token = $this->getAccessToken(); $statusText = $status === 'approved' ? '已通过' : '已拒绝'; $statusColor = $status === 'approved' ? 'green' : 'red'; + // 为每个消息使用其对应的飞书应用去更新 foreach ($messages as $message) { try { + $companyId = $message->company_id; + $feishuAppId = $message->feishu_app_id; + + if (!$companyId || !$feishuAppId) { + error_log("❌ 消息记录缺少 company_id 或 feishu_app_id: message_id={$message->message_id}"); + continue; + } + + error_log("🔍 准备更新消息: message_id={$message->message_id}, company_id={$companyId}, feishu_app_id={$feishuAppId}"); + + // 使用该消息对应的飞书应用配置 + $this->companyId = $companyId; + $this->appId = $feishuAppId; + // 构建更新后的卡片(移除操作按钮) $updatedCard = [ 'config' => [ @@ -714,86 +732,52 @@ class FeishuNotificationService [ 'tag' => 'div', 'text' => [ - 'content' => "**操作人**: {$operatorName}\n**操作结果**: {$statusText}\n\n此审批已被其他审批人处理,无需重复操作。", + 'content' => "**操作人**: {$operatorName}\n**操作结果**: {$statusText}\n\n此审批已被处理,无需重复操作。", 'tag' => 'lark_md', ], ], + [ + 'tag' => 'action', + 'actions' => [ + [ + 'tag' => 'button', + 'text' => [ + 'content' => '查看详情', + 'tag' => 'plain_text', + ], + 'type' => 'default', + 'url' => "https://uniservice-mobile.hnxinyouhe.com/approval/detail/{$orderId}?company={$companyId}", + ], + ], + ], ], ]; - $requestData = [ - 'message_id' => $message->message_id, - 'card' => $updatedCard, - 'status' => $status, - 'operator' => $operatorName, - ]; - - // content 必须是 card 对象的 JSON 字符串,不是 {"card":{...}} - // JSON_UNESCAPED_UNICODE 保留 emoji - // JSON_UNESCAPED_SLASHES 避免 URL 中的 / 被转义为 \/ $contentJson = json_encode($updatedCard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $response = HttpClient::httpPostJson( - "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}/update", + $response = HttpClient::httpPatchJson( + "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}", [ 'msg_type' => 'interactive', 'content' => $contentJson, ], - ['content'], // content 字段是预编码的 JSON 字符串 + ['content'], [ - 'Authorization: Bearer ' . $token, + 'Authorization: Bearer ' . $this->getAccessToken(), ] ); $code = $response['code'] ?? -1; if ($code === 0) { - error_log("✅ 消息卡片更新成功: message_id={$message->message_id}"); + error_log("✅ 消息卡片更新成功: message_id={$message->message_id}, company_id={$companyId}"); // 标记消息为已更新 $message->status = \app\model\FeishuMessage::STATUS_UPDATED; $message->save(); - - // 记录成功日志 - \app\model\FeishuMessageLog::log( - \app\model\FeishuMessageLog::ACTION_UPDATE_CARD, - $orderId, - $nodeId, - $message->user_id, - $message->message_id, - \app\model\FeishuMessageLog::STATUS_SUCCESS, - $requestData, - $response - ); } else { - error_log("❌ 消息卡片更新失败: message_id={$message->message_id}, error=" . json_encode($response)); - - // 记录失败日志 - \app\model\FeishuMessageLog::log( - \app\model\FeishuMessageLog::ACTION_UPDATE_CARD, - $orderId, - $nodeId, - $message->user_id, - $message->message_id, - \app\model\FeishuMessageLog::STATUS_FAILED, - $requestData, - $response, - $response['msg'] ?? 'Unknown error' - ); + error_log("❌ 消息卡片更新失败: message_id={$message->message_id}, company_id={$companyId}, error=" . json_encode($response)); } } catch (Exception $e) { error_log("❌ 更新消息卡片异常: message_id={$message->message_id}, error=" . $e->getMessage()); - - // 记录异常日志 - \app\model\FeishuMessageLog::log( - \app\model\FeishuMessageLog::ACTION_UPDATE_CARD, - $orderId, - $nodeId, - $message->user_id, - $message->message_id, - \app\model\FeishuMessageLog::STATUS_FAILED, - null, - null, - $e->getMessage() - ); } } @@ -834,16 +818,23 @@ class FeishuNotificationService // 获取订单申请人信息,从用户中获取公司ID $order = \app\model\ApprovalOrder::find($orderId); $companyId = null; + $applicantIdForLog = 'null'; if ($order) { + $applicantIdForLog = $order->applicant_id; $applicant = \app\model\User::find($order->applicant_id); if ($applicant && !empty($applicant->company_id)) { $companyId = $applicant->company_id; } } + error_log("🔍 updateMessageCards: order_id={$orderId}, applicant_id={$applicantIdForLog}, company_id={$companyId}"); + // 如果获取到公司ID,设置飞书应用配置 if ($companyId) { $this->setAppByUser($excludeUserId, $companyId); + error_log("✅ setAppByUser called: user_id={$excludeUserId}, company_id={$companyId}"); + } else { + error_log("❌ company_id is empty or order not found"); } $token = $this->getAccessToken(); @@ -914,8 +905,8 @@ class FeishuNotificationService $contentJson = json_encode($updatedCard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - $response = HttpClient::httpPostJson( - "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}/update", + $response = HttpClient::httpPatchJson( + "https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}", [ 'msg_type' => 'interactive', 'content' => $contentJson, diff --git a/backend/app/util/HttpClient.php b/backend/app/util/HttpClient.php index a664c19..dd94cb4 100644 --- a/backend/app/util/HttpClient.php +++ b/backend/app/util/HttpClient.php @@ -242,4 +242,75 @@ class HttpClient return $result; } + + /** + * 发送 HTTP PATCH JSON 请求 + * + * @param string $url 请求URL + * @param array $data 请求数据(将被编码为 JSON) + * @param array $preencodedKeys 需要预编码的字段名列表(这些字段的值已经是 JSON 字符串) + * @param array $headers 额外的请求头 + * @param int $timeout 超时时间(秒) + * @return array 响应数据(JSON解码后的数组) + * @throws Exception + */ + public static function httpPatchJson( + string $url, + array $data, + array $preencodedKeys = [], + array $headers = [], + int $timeout = 0 + ): array { + $ch = curl_init(); + + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + + // 手动构建 JSON,预编码字段作为字符串值直接插入 + $jsonParts = []; + foreach ($data as $key => $value) { + $keyJson = json_encode($key, JSON_UNESCAPED_UNICODE); + if (in_array($key, $preencodedKeys)) { + $jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE); + } + } + $jsonPayload = '{' . implode(',', $jsonParts) . '}'; + + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout ?: self::$defaultTimeout); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + + $defaultHeaders = [ + 'Content-Type: application/json', + ]; + + curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($defaultHeaders, $headers)); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + + // PHP 8.0+ curl 资源自动关闭,无需 curl_close() + + self::logRequest('PATCH', $url, $data, $response, $httpCode); + + if ($error) { + throw new Exception("HTTP PATCH 请求失败: {$error}"); + } + + if ($httpCode !== 200) { + throw new Exception("HTTP PATCH 请求失败: HTTP {$httpCode}, Response: {$response}"); + } + + $result = json_decode($response, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new Exception('JSON 解析失败: ' . $response); + } + + return $result; + } } diff --git a/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md b/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md index 9cace2e..72df2ff 100644 --- a/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md +++ b/docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md @@ -537,12 +537,263 @@ ORDER BY created_at DESC; --- +## 🏢 多公司多飞书应用架构 + +### 架构概述 + +在多租户场景下,每个公司可以有独立的飞书应用配置: +- 不同的 `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-02-28 +**最后更新**: 2026-03-01 diff --git a/docs/PROJECT_ISSUES.md b/docs/PROJECT_ISSUES.md index d0ba21f..6478262 100644 --- a/docs/PROJECT_ISSUES.md +++ b/docs/PROJECT_ISSUES.md @@ -1046,3 +1046,192 @@ echo 'opcache.enable=0' >> /usr/local/etc/php/conf.d/99-disable-opcache.ini 2. 登录页需要优先检查 token,已登录则直接跳转 3. 多公司支持:URL 需要包含 `?company={id}` 参数 4. 飞书卡片通知 URL 格式:`https://xsm.dev.hnxinyouhe.com/approval/detail/{order_id}?company={company_id}` + +--- + +## 31. 多公司多飞书应用消息卡片更新失败 + +**问题**: +在多公司多飞书应用场景下,当多个审批人中有一人拒绝/通过时,其他审批人的消息卡片没有更新,仍然显示操作按钮。 + +**场景描述**: +- 公司A和公司B的员工共同在一个审批流程中 +- 公司A的bot发送了消息给公司A的员工 +- 公司B的bot发送了消息给公司B的员工 +- 当公司A的员工操作后,需要更新公司B员工的消息卡片 +- 使用公司A的bot去更新公司B发送的消息 → 失败 + +**错误信息**: +``` +HTTP 400 - bot or user is not the message's sender +``` + +**根本原因**: +1. **飞书API限制**:只有发送消息的bot才能更新该消息 +2. **架构问题**:多公司场景下,每个公司有独立的飞书应用(app_id + app_secret) +3. **数据缺失**:`feishu_message` 表没有记录是哪个飞书应用发送的消息 + +**解决方案**: + +### 1. 数据库迁移 +```sql +-- 添加 company_id 和 feishu_app_id 字段 +ALTER TABLE feishu_message +ADD COLUMN company_id BIGINT UNSIGNED NULL AFTER user_id, +ADD COLUMN feishu_app_id INT UNSIGNED NULL AFTER company_id, +ADD INDEX idx_company (company_id); + +-- 更新现有数据(如有) +UPDATE feishu_message fm +JOIN approval_order ao ON fm.order_id = ao.id +JOIN user u ON ao.applicant_id = u.id +SET fm.company_id = u.company_id; +``` + +### 2. 修改 FeishuMessage 模型 +```php +// backend/app/model/FeishuMessage.php +protected $fillable = [ + 'order_id', + 'node_id', + 'user_id', + 'company_id', // 新增 + 'feishu_app_id', // 新增 + 'message_id', + 'msg_token', + 'status', +]; + +public static function saveMessage( + int $orderId, + int $nodeId, + int $userId, + string $messageId, + ?string $msgToken = null, + ?int $companyId = null, // 新增参数 + ?int $feishuAppId = null // 新增参数 +): self { + return self::updateOrCreate( + [ + 'order_id' => $orderId, + 'node_id' => $nodeId, + 'user_id' => $userId, + ], + [ + 'message_id' => $messageId, + 'msg_token' => $msgToken, + 'company_id' => $companyId, + 'feishu_app_id' => $feishuAppId, + 'status' => self::STATUS_ACTIVE, + ] + ); +} +``` + +### 3. 修改发送逻辑 +```php +// backend/app/service/FeishuNotificationService.php +// 发送消息时保存 company_id 和 feishu_app_id +\app\model\FeishuMessage::saveMessage( + $orderId, + $nodeId, + $userId, + $messageId, + null, + $user->company_id, // 保存公司ID + $this->appId // 保存飞书应用ID +); +``` + +### 4. 修改更新逻辑 +```php +// backend/app/service/FeishuNotificationService.php +public function updateMessageCards( + int $orderId, + int $nodeId, + ?int $excludeUserId = null, + string $result = 'approved', + string $operatorName = '' +): bool { + $messages = \app\model\FeishuMessage::getMessagesByOrderAndNode($orderId, $nodeId); + + foreach ($messages as $message) { + // 获取消息发送时的公司ID和飞书应用ID + $companyId = $message->company_id; + $feishuAppId = $message->feishu_app_id; + + if (!$companyId || !$feishuAppId) { + error_log("❌ 消息记录缺少 company_id 或 feishu_app_id"); + continue; + } + + // 临时切换到该消息对应的飞书应用配置 + $originalCompanyId = $this->companyId; + $originalAppId = $this->appId; + + $this->companyId = $companyId; + $this->appId = $feishuAppId; + + try { + $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()] + ); + } finally { + // 恢复原始配置 + $this->companyId = $originalCompanyId; + $this->appId = $originalAppId; + } + } + + return true; +} +``` + +### 5. 添加 httpPatchJson 方法 +```php +// backend/app/util/HttpClient.php +public static function httpPatchJson( + string $url, + array $data, + array $preencodedKeys = [], + array $headers = [], + int $timeout = 0 +): array { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + + // 手动构建 JSON + $jsonParts = []; + foreach ($data as $key => $value) { + $keyJson = json_encode($key, JSON_UNESCAPED_UNICODE); + if (in_array($key, $preencodedKeys)) { + $jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } else { + $jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE); + } + } + $jsonPayload = '{' . implode(',', $jsonParts) . '}'; + + curl_setopt($ch, CURLOPT_POSTFIELDS, $jsonPayload); + // ... 其余代码 +} +``` + +**关键点**: +1. ✅ 每个飞书应用只能更新自己发送的消息 +2. ✅ 需要在数据库中记录每条消息是由哪个飞书应用发送的 +3. ✅ 更新消息时使用原始发送者的飞书应用配置 +4. ✅ 使用 PATCH `/messages/{id}` 而不是 POST `/messages/{id}/update` + +**相关文件**: +- `backend/app/model/FeishuMessage.php` - 添加 company_id 和 feishu_app_id 字段 +- `backend/app/service/FeishuNotificationService.php` - 修改发送和更新逻辑 +- `backend/app/util/HttpClient.php` - 添加 httpPatchJson 方法 +- `docs/APPROVAL_NOTIFICATION_ARCHITECTURE.md` - 架构文档更新 + +**飞书API文档**: +- 更新消息:https://open.feishu.cn/document/server-docs/im-v1/message/update_messages