fix: 修复审批详情显示实际审批人和飞书消息emoji编码
1. 审批详情显示修复
- 新增 ApprovalService::getDetail() 方法
- 从 form_data 获取用户实际选择的审批人和抄送人
- 返回每个节点的 actual_approvers 和 approver_statuses
- 修复详情页显示默认审批人而非实际审批人的问题
2. 飞书消息emoji编码修复
- 修复 content 字段格式:应为 card 对象JSON字符串,不是 {"card":{...}}
- 使用 JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
- 新增 HttpClient::httpPostJson() 方法处理预编码字段
- 修复消息发送错误码 200621
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,10 +60,12 @@ class ApprovalController extends BaseController
|
|||||||
{
|
{
|
||||||
$id = (int) $this->getRouteParam($request, 'id');
|
$id = (int) $this->getRouteParam($request, 'id');
|
||||||
|
|
||||||
$approval = ApprovalOrder::with(['flow.nodes', 'applicant', 'records.approver'])
|
try {
|
||||||
->findOrFail($id);
|
$detail = $this->service->getDetail($id);
|
||||||
|
return $this->success($detail);
|
||||||
return $this->success($approval);
|
} catch (\Exception $e) {
|
||||||
|
return $this->error($e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -252,6 +252,108 @@ class ApprovalService
|
|||||||
return $order->fresh(['flow', 'applicant', 'records']);
|
return $order->fresh(['flow', 'applicant', 'records']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取审批详情(包含实际审批人信息)
|
||||||
|
*/
|
||||||
|
public function getDetail(int $orderId): array
|
||||||
|
{
|
||||||
|
$order = ApprovalOrder::with(['flow.nodes', 'applicant', 'records.approver'])
|
||||||
|
->findOrFail($orderId);
|
||||||
|
|
||||||
|
// 获取 form_data 中的实际审批人和抄送人
|
||||||
|
$formData = $order->form_data ?? [];
|
||||||
|
$actualApprovers = $formData['approvers'] ?? [];
|
||||||
|
$actualCcUsers = $formData['cc_users'] ?? [];
|
||||||
|
|
||||||
|
// 兼容两种格式:对象数组 [{id:1, name:'xxx'}] 或纯ID数组 [1, 2, 3]
|
||||||
|
if (!empty($actualApprovers) && isset($actualApprovers[0]['id'])) {
|
||||||
|
$actualApproverIds = array_column($actualApprovers, 'id');
|
||||||
|
} else {
|
||||||
|
$actualApproverIds = $actualApprovers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($actualCcUsers) && isset($actualCcUsers[0]['id'])) {
|
||||||
|
$actualCcUserIds = array_column($actualCcUsers, 'id');
|
||||||
|
} else {
|
||||||
|
$actualCcUserIds = $actualCcUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实际审批人的完整用户信息
|
||||||
|
$actualApproverUsers = [];
|
||||||
|
if (!empty($actualApproverIds)) {
|
||||||
|
$actualApproverUsers = User::whereIn('id', $actualApproverIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取实际抄送人的完整用户信息
|
||||||
|
$actualCcUserObjects = [];
|
||||||
|
if (!empty($actualCcUserIds)) {
|
||||||
|
$actualCcUserObjects = User::whereIn('id', $actualCcUserIds)
|
||||||
|
->get()
|
||||||
|
->keyBy('id')
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理节点信息,添加实际审批人
|
||||||
|
$nodes = [];
|
||||||
|
if ($order->flow && $order->flow->nodes) {
|
||||||
|
foreach ($order->flow->nodes as $node) {
|
||||||
|
$nodeArray = $node->toArray();
|
||||||
|
|
||||||
|
// 为审批节点添加实际审批人信息
|
||||||
|
if (in_array($node->type, ['approver', 'and_approve', 'or_approve'])) {
|
||||||
|
$nodeArray['actual_approvers'] = [];
|
||||||
|
foreach ($actualApproverIds as $approverId) {
|
||||||
|
if (isset($actualApproverUsers[$approverId])) {
|
||||||
|
$nodeArray['actual_approvers'][] = $actualApproverUsers[$approverId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取每个实际审批人的审批状态
|
||||||
|
$nodeArray['approver_statuses'] = [];
|
||||||
|
foreach ($actualApproverIds as $approverId) {
|
||||||
|
$record = ApprovalRecord::where('order_id', $orderId)
|
||||||
|
->where('node_id', $node->id)
|
||||||
|
->where('approver_id', $approverId)
|
||||||
|
->whereIn('action', ['approve', 'reject'])
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$nodeArray['approver_statuses'][] = [
|
||||||
|
'user_id' => $approverId,
|
||||||
|
'user' => $actualApproverUsers[$approverId] ?? null,
|
||||||
|
'action' => $record->action ?? null,
|
||||||
|
'action_name' => $record ? ($record->action === 'approve' ? '已通过' : '已拒绝') : '待审批',
|
||||||
|
'comment' => $record->comment ?? null,
|
||||||
|
'created_at' => $record->created_at ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为通知节点添加实际抄送人
|
||||||
|
if ($node->type === 'notify') {
|
||||||
|
$nodeArray['actual_cc_users'] = [];
|
||||||
|
foreach ($actualCcUserIds as $ccUserId) {
|
||||||
|
if (isset($actualCcUserObjects[$ccUserId])) {
|
||||||
|
$nodeArray['actual_cc_users'][] = $actualCcUserObjects[$ccUserId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$nodes[] = $nodeArray;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建返回数据
|
||||||
|
$result = $order->toArray();
|
||||||
|
$result['flow']['nodes'] = $nodes;
|
||||||
|
$result['actual_approvers'] = array_values($actualApproverUsers);
|
||||||
|
$result['actual_cc_users'] = array_values($actualCcUserObjects);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取审批列表
|
* 获取审批列表
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -566,13 +566,19 @@ class FeishuNotificationService
|
|||||||
error_log('📤 发送飞书消息: app_id=' . $app['app_id'] . ', feishu_user_id=' . $feishuUserId);
|
error_log('📤 发送飞书消息: app_id=' . $app['app_id'] . ', feishu_user_id=' . $feishuUserId);
|
||||||
error_log('📦 消息卡片: ' . json_encode($card, JSON_UNESCAPED_UNICODE));
|
error_log('📦 消息卡片: ' . json_encode($card, JSON_UNESCAPED_UNICODE));
|
||||||
|
|
||||||
$response = HttpClient::httpPost(
|
// content 必须是 card 对象的 JSON 字符串,不是 {"card":{...}}
|
||||||
|
// JSON_UNESCAPED_UNICODE 保留 emoji
|
||||||
|
// JSON_UNESCAPED_SLASHES 避免 URL 中的 / 被转义为 \/
|
||||||
|
$contentJson = json_encode($card, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
|
$response = HttpClient::httpPostJson(
|
||||||
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id",
|
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id",
|
||||||
[
|
[
|
||||||
'receive_id' => $feishuUserId,
|
'receive_id' => $feishuUserId,
|
||||||
'msg_type' => 'interactive',
|
'msg_type' => 'interactive',
|
||||||
'content' => ['card' => $card], // 直接传递数组,让 HttpClient 进行编码
|
'content' => $contentJson,
|
||||||
],
|
],
|
||||||
|
['content'], // content 字段是预编码的 JSON 字符串
|
||||||
[
|
[
|
||||||
'Authorization: Bearer ' . $token,
|
'Authorization: Bearer ' . $token,
|
||||||
]
|
]
|
||||||
@@ -707,13 +713,18 @@ class FeishuNotificationService
|
|||||||
'operator' => $operatorName,
|
'operator' => $operatorName,
|
||||||
];
|
];
|
||||||
|
|
||||||
$response = HttpClient::httpPost(
|
// 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",
|
"https://open.feishu.cn/open-apis/im/v1/messages/{$message->message_id}/update",
|
||||||
[
|
[
|
||||||
'msg_type' => 'interactive',
|
'msg_type' => 'interactive',
|
||||||
// 直接传递数组,让 HttpClient 进行编码
|
'content' => $contentJson,
|
||||||
'content' => ['card' => $updatedCard],
|
|
||||||
],
|
],
|
||||||
|
['content'], // content 字段是预编码的 JSON 字符串
|
||||||
[
|
[
|
||||||
'Authorization: Bearer ' . $token,
|
'Authorization: Bearer ' . $token,
|
||||||
]
|
]
|
||||||
@@ -826,15 +837,18 @@ class FeishuNotificationService
|
|||||||
|
|
||||||
$apiUrl = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id";
|
$apiUrl = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=user_id";
|
||||||
|
|
||||||
|
// content 必须是 JSON 字符串,使用 httpPostJson 避免二次编码
|
||||||
|
$contentJson = json_encode(['text' => $text], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$requestData = [
|
$requestData = [
|
||||||
'receive_id' => $feishuUserId,
|
'receive_id' => $feishuUserId,
|
||||||
'msg_type' => 'text',
|
'msg_type' => 'text',
|
||||||
'content' => json_encode(['text' => $text]),
|
'content' => $contentJson,
|
||||||
];
|
];
|
||||||
|
|
||||||
error_log('📤 发送文本消息: feishu_user_id=' . $feishuUserId . ', text=' . substr($text, 0, 100));
|
error_log('📤 发送文本消息: feishu_user_id=' . $feishuUserId . ', text=' . substr($text, 0, 100));
|
||||||
|
|
||||||
$responseData = \app\util\HttpClient::httpPost($apiUrl, $requestData, [
|
$responseData = \app\util\HttpClient::httpPostJson($apiUrl, $requestData, ['content'], [
|
||||||
'Authorization: Bearer ' . $accessToken,
|
'Authorization: Bearer ' . $accessToken,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ class HttpClient
|
|||||||
error_log($logMessage);
|
error_log($logMessage);
|
||||||
|
|
||||||
if (!empty($requestData)) {
|
if (!empty($requestData)) {
|
||||||
error_log("📤 Request: " . json_encode($requestData));
|
error_log("📤 Request: " . json_encode($requestData, JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
error_log("📥 Response: {$response}");
|
error_log("📥 Response: {$response}");
|
||||||
@@ -163,4 +163,83 @@ class HttpClient
|
|||||||
{
|
{
|
||||||
self::$defaultTimeout = $timeout;
|
self::$defaultTimeout = $timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送 HTTP POST 请求(原始 JSON 字符串)
|
||||||
|
* 用于某些字段需要预编码的场景(如飞书消息的 content 字段)
|
||||||
|
*
|
||||||
|
* @param string $url 请求URL
|
||||||
|
* @param array $data 请求数据(某些值可以是预编码的 JSON 字符串)
|
||||||
|
* @param array $preencodedKeys 需要预编码的键名数组
|
||||||
|
* @param array $headers 请求头
|
||||||
|
* @param int $timeout 超时时间(秒)
|
||||||
|
* @return array 响应数据(JSON解码后的数组)
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public static function httpPostJson(
|
||||||
|
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_POST, true);
|
||||||
|
|
||||||
|
// 手动构建 JSON,预编码字段作为字符串值直接插入
|
||||||
|
$jsonParts = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$keyJson = json_encode($key, JSON_UNESCAPED_UNICODE);
|
||||||
|
if (in_array($key, $preencodedKeys)) {
|
||||||
|
// 预编码的字段:值已经是 JSON 字符串,需要作为字符串值插入
|
||||||
|
// JSON_UNESCAPED_UNICODE 保留 emoji
|
||||||
|
// JSON_UNESCAPED_SLASHES 避免再次转义 URL 中的 /
|
||||||
|
$jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
} else {
|
||||||
|
// 其他字段正常编码
|
||||||
|
$jsonParts[] = $keyJson . ':' . json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$jsonPayload = '{' . implode(',', $jsonParts) . '}';
|
||||||
|
|
||||||
|
// Debug: log the actual payload being sent
|
||||||
|
error_log('🚀 Actual curl payload: ' . $jsonPayload);
|
||||||
|
|
||||||
|
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('POST', $url, $data, $response, $httpCode);
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
throw new Exception("HTTP POST 请求失败: {$error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode !== 200) {
|
||||||
|
throw new Exception("HTTP POST 请求失败: HTTP {$httpCode}, Response: {$response}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = json_decode($response, true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
throw new Exception('JSON 解析失败: ' . $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user