fix: 修复多公司多飞书应用消息卡片更新失败问题
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@ class FeishuMessage extends Model
|
|||||||
'order_id',
|
'order_id',
|
||||||
'node_id',
|
'node_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
|
'company_id',
|
||||||
|
'feishu_app_id',
|
||||||
'message_id',
|
'message_id',
|
||||||
'msg_token',
|
'msg_token',
|
||||||
'status',
|
'status',
|
||||||
@@ -44,7 +46,9 @@ class FeishuMessage extends Model
|
|||||||
int $nodeId,
|
int $nodeId,
|
||||||
int $userId,
|
int $userId,
|
||||||
string $messageId,
|
string $messageId,
|
||||||
?string $msgToken = null
|
?string $msgToken = null,
|
||||||
|
?int $companyId = null,
|
||||||
|
?int $feishuAppId = null
|
||||||
): self {
|
): self {
|
||||||
return self::updateOrCreate(
|
return self::updateOrCreate(
|
||||||
[
|
[
|
||||||
@@ -55,6 +59,8 @@ class FeishuMessage extends Model
|
|||||||
[
|
[
|
||||||
'message_id' => $messageId,
|
'message_id' => $messageId,
|
||||||
'msg_token' => $msgToken,
|
'msg_token' => $msgToken,
|
||||||
|
'company_id' => $companyId,
|
||||||
|
'feishu_app_id' => $feishuAppId,
|
||||||
'status' => self::STATUS_ACTIVE,
|
'status' => self::STATUS_ACTIVE,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -163,9 +163,17 @@ class FeishuNotificationService
|
|||||||
$messageId = $this->sendMessage($feishuUserId, $card, $orderId, $nodeId, $userId);
|
$messageId = $this->sendMessage($feishuUserId, $card, $orderId, $nodeId, $userId);
|
||||||
|
|
||||||
if ($messageId && $orderId && $nodeId) {
|
if ($messageId && $orderId && $nodeId) {
|
||||||
// 保存消息映射
|
// 保存消息映射(包含公司ID和飞书应用ID)
|
||||||
\app\model\FeishuMessage::saveMessage($orderId, $nodeId, $userId, $messageId);
|
\app\model\FeishuMessage::saveMessage(
|
||||||
error_log("💾 保存消息映射: order_id={$orderId}, node_id={$nodeId}, user_id={$userId}, message_id={$messageId}");
|
$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;
|
return $messageId !== false;
|
||||||
@@ -662,6 +670,17 @@ class FeishuNotificationService
|
|||||||
* @param string $operatorName 操作人姓名
|
* @param string $operatorName 操作人姓名
|
||||||
* @return bool
|
* @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
|
public function updateMessageCards(int $orderId, int $nodeId, int $excludeUserId, string $status, string $operatorName): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -677,27 +696,26 @@ class FeishuNotificationService
|
|||||||
return true;
|
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' ? '已通过' : '已拒绝';
|
$statusText = $status === 'approved' ? '已通过' : '已拒绝';
|
||||||
$statusColor = $status === 'approved' ? 'green' : 'red';
|
$statusColor = $status === 'approved' ? 'green' : 'red';
|
||||||
|
|
||||||
|
// 为每个消息使用其对应的飞书应用去更新
|
||||||
foreach ($messages as $message) {
|
foreach ($messages as $message) {
|
||||||
try {
|
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 = [
|
$updatedCard = [
|
||||||
'config' => [
|
'config' => [
|
||||||
@@ -714,86 +732,52 @@ class FeishuNotificationService
|
|||||||
[
|
[
|
||||||
'tag' => 'div',
|
'tag' => 'div',
|
||||||
'text' => [
|
'text' => [
|
||||||
'content' => "**操作人**: {$operatorName}\n**操作结果**: {$statusText}\n\n此审批已被其他审批人处理,无需重复操作。",
|
'content' => "**操作人**: {$operatorName}\n**操作结果**: {$statusText}\n\n此审批已被处理,无需重复操作。",
|
||||||
'tag' => 'lark_md',
|
'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);
|
$contentJson = json_encode($updatedCard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
$response = HttpClient::httpPostJson(
|
$response = HttpClient::httpPatchJson(
|
||||||
"https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}/update",
|
"https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}",
|
||||||
[
|
[
|
||||||
'msg_type' => 'interactive',
|
'msg_type' => 'interactive',
|
||||||
'content' => $contentJson,
|
'content' => $contentJson,
|
||||||
],
|
],
|
||||||
['content'], // content 字段是预编码的 JSON 字符串
|
['content'],
|
||||||
[
|
[
|
||||||
'Authorization: Bearer ' . $token,
|
'Authorization: Bearer ' . $this->getAccessToken(),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$code = $response['code'] ?? -1;
|
$code = $response['code'] ?? -1;
|
||||||
if ($code === 0) {
|
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->status = \app\model\FeishuMessage::STATUS_UPDATED;
|
||||||
$message->save();
|
$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 {
|
} else {
|
||||||
error_log("❌ 消息卡片更新失败: message_id={$message->message_id}, error=" . json_encode($response));
|
error_log("❌ 消息卡片更新失败: message_id={$message->message_id}, company_id={$companyId}, 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'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
error_log("❌ 更新消息卡片异常: message_id={$message->message_id}, error=" . $e->getMessage());
|
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
|
// 获取订单申请人信息,从用户中获取公司ID
|
||||||
$order = \app\model\ApprovalOrder::find($orderId);
|
$order = \app\model\ApprovalOrder::find($orderId);
|
||||||
$companyId = null;
|
$companyId = null;
|
||||||
|
$applicantIdForLog = 'null';
|
||||||
if ($order) {
|
if ($order) {
|
||||||
|
$applicantIdForLog = $order->applicant_id;
|
||||||
$applicant = \app\model\User::find($order->applicant_id);
|
$applicant = \app\model\User::find($order->applicant_id);
|
||||||
if ($applicant && !empty($applicant->company_id)) {
|
if ($applicant && !empty($applicant->company_id)) {
|
||||||
$companyId = $applicant->company_id;
|
$companyId = $applicant->company_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
error_log("🔍 updateMessageCards: order_id={$orderId}, applicant_id={$applicantIdForLog}, company_id={$companyId}");
|
||||||
|
|
||||||
// 如果获取到公司ID,设置飞书应用配置
|
// 如果获取到公司ID,设置飞书应用配置
|
||||||
if ($companyId) {
|
if ($companyId) {
|
||||||
$this->setAppByUser($excludeUserId, $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();
|
$token = $this->getAccessToken();
|
||||||
@@ -914,8 +905,8 @@ class FeishuNotificationService
|
|||||||
|
|
||||||
$contentJson = json_encode($updatedCard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
$contentJson = json_encode($updatedCard, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
$response = HttpClient::httpPostJson(
|
$response = HttpClient::httpPatchJson(
|
||||||
"https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}/update",
|
"https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}",
|
||||||
[
|
[
|
||||||
'msg_type' => 'interactive',
|
'msg_type' => 'interactive',
|
||||||
'content' => $contentJson,
|
'content' => $contentJson,
|
||||||
|
|||||||
@@ -242,4 +242,75 @@ class HttpClient
|
|||||||
|
|
||||||
return $result;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
- [数据库结构文档](./DATABASE_SCHEMA.md)
|
||||||
- [项目文档](./PROJECT.md)
|
- [项目文档](./PROJECT.md)
|
||||||
|
- [问题汇总](./PROJECT_ISSUES.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**维护人**: 开发团队
|
**维护人**: 开发团队
|
||||||
**最后更新**: 2026-02-28
|
**最后更新**: 2026-03-01
|
||||||
|
|||||||
@@ -1046,3 +1046,192 @@ echo 'opcache.enable=0' >> /usr/local/etc/php/conf.d/99-disable-opcache.ini
|
|||||||
2. 登录页需要优先检查 token,已登录则直接跳转
|
2. 登录页需要优先检查 token,已登录则直接跳转
|
||||||
3. 多公司支持:URL 需要包含 `?company={id}` 参数
|
3. 多公司支持:URL 需要包含 `?company={id}` 参数
|
||||||
4. 飞书卡片通知 URL 格式:`https://xsm.dev.hnxinyouhe.com/approval/detail/{order_id}?company={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
|
||||||
|
|||||||
Reference in New Issue
Block a user