diff --git a/backend/app/controller/ApprovalController.php b/backend/app/controller/ApprovalController.php index bd2d352..75769b8 100644 --- a/backend/app/controller/ApprovalController.php +++ b/backend/app/controller/ApprovalController.php @@ -60,10 +60,12 @@ class ApprovalController extends BaseController { $id = (int) $this->getRouteParam($request, 'id'); - $approval = ApprovalOrder::with(['flow.nodes', 'applicant', 'records.approver']) - ->findOrFail($id); - - return $this->success($approval); + try { + $detail = $this->service->getDetail($id); + return $this->success($detail); + } catch (\Exception $e) { + return $this->error($e->getMessage()); + } } /** diff --git a/backend/app/service/ApprovalService.php b/backend/app/service/ApprovalService.php index acc4f24..8e6e74b 100644 --- a/backend/app/service/ApprovalService.php +++ b/backend/app/service/ApprovalService.php @@ -252,6 +252,108 @@ class ApprovalService 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; + } + /** * 获取审批列表 */ diff --git a/backend/app/service/FeishuNotificationService.php b/backend/app/service/FeishuNotificationService.php index c261bf2..5bd51f7 100644 --- a/backend/app/service/FeishuNotificationService.php +++ b/backend/app/service/FeishuNotificationService.php @@ -566,13 +566,19 @@ class FeishuNotificationService error_log('📤 发送飞书消息: app_id=' . $app['app_id'] . ', feishu_user_id=' . $feishuUserId); 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", [ 'receive_id' => $feishuUserId, 'msg_type' => 'interactive', - 'content' => ['card' => $card], // 直接传递数组,让 HttpClient 进行编码 + 'content' => $contentJson, ], + ['content'], // content 字段是预编码的 JSON 字符串 [ 'Authorization: Bearer ' . $token, ] @@ -707,13 +713,18 @@ class FeishuNotificationService '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", [ 'msg_type' => 'interactive', - // 直接传递数组,让 HttpClient 进行编码 - 'content' => ['card' => $updatedCard], + 'content' => $contentJson, ], + ['content'], // content 字段是预编码的 JSON 字符串 [ 'Authorization: Bearer ' . $token, ] @@ -826,15 +837,18 @@ class FeishuNotificationService $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 = [ 'receive_id' => $feishuUserId, 'msg_type' => 'text', - 'content' => json_encode(['text' => $text]), + 'content' => $contentJson, ]; 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, ]); diff --git a/backend/app/util/HttpClient.php b/backend/app/util/HttpClient.php index d513404..a664c19 100644 --- a/backend/app/util/HttpClient.php +++ b/backend/app/util/HttpClient.php @@ -148,7 +148,7 @@ class HttpClient error_log($logMessage); if (!empty($requestData)) { - error_log("📤 Request: " . json_encode($requestData)); + error_log("📤 Request: " . json_encode($requestData, JSON_UNESCAPED_UNICODE)); } error_log("📥 Response: {$response}"); @@ -163,4 +163,83 @@ class HttpClient { 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; + } }