rawBody(); $data = json_decode($body, true); error_log('📥 飞书卡片回调: ' . json_encode($data, JSON_UNESCAPED_UNICODE)); if (!$data) { error_log('❌ 无效的 JSON 数据'); return $this->json(['code' => -1, 'msg' => 'Invalid JSON']); } // 获取事件类型 $eventType = $data['type'] ?? $data['header']['event_type'] ?? ''; // URL 验证 if ($eventType === 'url_verification') { return $this->handleUrlVerification($data); } // 卡片交互回调 - 兼容两种格式 if ($eventType === 'card.callback' || $eventType === 'card.action.trigger') { return $this->handleCardCallback($data); } error_log('❌ 未知的回调类型: ' . $eventType); return $this->json(['code' => -1, 'msg' => 'Unknown callback type']); } /** * 处理 URL 验证请求 * 飞书在配置回调地址时会先验证 URL 的有效性 */ private function handleUrlVerification(array $data): Response { $challenge = $data['challenge'] ?? ''; if (!$challenge) { error_log('❌ URL 验证缺少 challenge'); return $this->json(['code' => -1, 'msg' => 'Missing challenge']); } error_log('✅ URL 验证成功'); // URL 验证需要直接返回 challenge,不用包装 $response = json_encode(['challenge' => $challenge]); return response($response, 200, ['Content-Type' => 'application/json']); } /** * 处理卡片按钮点击回调 * 支持两种格式: * 1. 简单格式: {type: 'card.callback', token: {...}, action: {...}, operator: {...}} * 2. 飞书实际格式: {header: {event_type: 'card.action.trigger'}, event: {operator: {...}, token: {...}, action: {...}}} */ private function handleCardCallback(array $data): Response { try { // 兼容两种格式 $isEventFormat = isset($data['header']['event_type']); if ($isEventFormat) { // 飞书实际格式: {header: {...}, event: {...}} $eventData = $data['event'] ?? []; $operator = $eventData['operator'] ?? []; $action = $eventData['action'] ?? []; // 从 header 获取 app_id 和 msg_token $appId = $data['header']['app_id'] ?? null; $msgToken = $eventData['token'] ?? null; } else { // 简单格式: {token: {...}, action: {...}, operator: {...}} $token = $data['token'] ?? []; $appId = $token['app_id'] ?? null; $operator = $data['operator'] ?? []; $action = $data['action'] ?? []; $msgToken = null; } if (!$appId) { error_log('❌ 缺少 app_id'); return $this->errorCard('无效的应用'); } // 查找飞书应用配置 $feishuApp = CompanyFeishuApp::where('app_id', $appId) ->where('status', 1) ->first(); if (!$feishuApp) { error_log('❌ 飞书应用不存在: ' . $appId); return $this->errorCard('应用配置不存在'); } // 获取 action value 和 form value(表单提交时) $actionValue = $action['value'] ?? []; $formValue = $action['form_value'] ?? []; // 合并按钮值和表单值(表单值优先) $actionValue = array_merge($actionValue, $formValue); $openId = $operator['open_id'] ?? null; if (!$openId) { error_log('❌ 缺少 open_id'); return $this->errorCard('无法识别用户'); } // 根据 open_id 查找用户 $userBinding = UserFeishuApp::where('feishu_open_id', $openId) ->where('company_id', $feishuApp->company_id) ->where('feishu_app_id', $feishuApp->id) ->first(); if (!$userBinding) { error_log('❌ 用户未绑定飞书应用: open_id=' . $openId); return $this->errorCard('用户未登录,请先登录系统'); } $user = User::find($userBinding->user_id); if (!$user || $user->status !== 1) { error_log('❌ 用户不存在或已禁用: user_id=' . ($userBinding->user_id ?? 'null')); return $this->errorCard('用户不存在或已被禁用'); } // 处理不同类型的操作 $actionType = $actionValue['action_type'] ?? ''; error_log('🎯 卡片操作类型: ' . $actionType . ', user_id: ' . $user->id . ', msg_token: ' . ($msgToken ?? 'null')); switch ($actionType) { case 'approve': return $this->handleApprove($user, $actionValue, $msgToken); case 'reject': return $this->handleReject($user, $actionValue, $msgToken); default: error_log('❌ 未知的操作类型: ' . $actionType); return $this->errorCard('未知的操作类型'); } } catch (\Exception $e) { error_log('❌ 处理卡片回调异常: ' . $e->getMessage()); return $this->errorCard('处理失败: ' . $e->getMessage()); } } /** * 处理审批通过 */ private function handleApprove(User $user, array $actionValue, ?string $msgToken = null): Response { $orderId = $actionValue['order_id'] ?? null; $remark = $actionValue['remark'] ?? ''; // 从表单获取备注 if (!$orderId) { error_log('❌ 缺少 order_id'); return $this->errorCard('缺少审批单ID'); } try { $approvalService = new ApprovalService(); // 检查用户是否有权限审批 $order = \app\model\ApprovalOrder::find($orderId); if (!$order) { error_log('❌ 审批单不存在: order_id=' . $orderId); return $this->errorCard('审批单不存在'); } error_log('📝 开始审批通过: order_id=' . $orderId . ', user_id=' . $user->id . ', remark=' . $remark); // 执行审批通过 $approvalService->approve($orderId, $user->id, $remark); error_log('✅ 审批通过成功,准备返回卡片'); $response = $this->successCard('审批通过', $this->buildApproveContent($user, $order, $remark)); error_log('✅ 卡片响应已生成'); return $response; } catch (\Exception $e) { error_log('❌ 审批通过异常: ' . $e->getMessage() . ', file: ' . $e->getFile() . ', line: ' . $e->getLine()); return $this->errorCard('审批失败: ' . $e->getMessage()); } } /** * 构建审批通过后的卡片内容 */ private function buildApproveContent(User $user, \app\model\ApprovalOrder $order, string $remark = ''): string { $applicant = \app\model\User::find($order->applicant_id); $applicantName = $applicant ? $applicant->name : '未知'; // 获取订单类型名称 $typeName = $order->type ?? '申请'; $parts = [ "{$user->name} 已通过该申请", "", "**申请人**: {$applicantName}", "**申请单号**: #{$order->id}", "**申请类型**: {$typeName}", ]; // 如果有备注,添加备注 if ($remark) { $parts[] = "**审批备注**: {$remark}"; } // 如果有额外数据,添加更多细节 if ($order->data) { $data = json_decode($order->data, true); if (is_array($data)) { // 添加一些关键信息 if (!empty($data['reason'])) { $parts[] = "**申请事由**: {$data['reason']}"; } if (!empty($data['start_date'])) { $parts[] = "**开始时间**: {$data['start_date']}"; } if (!empty($data['end_date'])) { $parts[] = "**结束时间**: {$data['end_date']}"; } } } $parts[] = ""; $parts[] = "_操作时间: " . date('Y-m-d H:i:s') . "_"; return implode("\n", $parts); } /** * 处理审批拒绝 */ private function handleReject(User $user, array $actionValue, ?string $msgToken = null): Response { $orderId = $actionValue['order_id'] ?? null; $reason = $actionValue['remark'] ?? '已拒绝'; // 从表单获取备注,没有则用默认值 if (!$orderId) { error_log('❌ 缺少 order_id'); return $this->errorCard('缺少审批单ID'); } try { $approvalService = new ApprovalService(); // 检查用户是否有权限审批 $order = \app\model\ApprovalOrder::find($orderId); if (!$order) { error_log('❌ 审批单不存在: order_id=' . $orderId); return $this->errorCard('审批单不存在'); } error_log('📝 开始审批拒绝: order_id=' . $orderId . ', user_id=' . $user->id . ', reason=' . $reason); // 执行审批拒绝 $approvalService->reject($orderId, $user->id, $reason); error_log('✅ 审批拒绝成功,准备返回卡片'); $response = $this->successCard('已拒绝', $this->buildRejectContent($user, $order, $reason)); error_log('✅ 卡片响应已生成'); return $response; } catch (\Exception $e) { error_log('❌ 审批拒绝异常: ' . $e->getMessage() . ', file: ' . $e->getFile() . ', line: ' . $e->getLine()); return $this->errorCard('审批失败: ' . $e->getMessage()); } } /** * 构建拒绝成功后的卡片内容 */ private function buildRejectContent(User $user, \app\model\ApprovalOrder $order, string $reason): string { $applicant = \app\model\User::find($order->applicant_id); $applicantName = $applicant ? $applicant->name : '未知'; $parts = [ "{$user->name} 已拒绝该申请", "", "**申请人**: {$applicantName}", "**申请单号**: #{$order->id}", ]; // 如果有拒绝原因,显示 if ($reason && $reason !== '已拒绝') { $parts[] = "**拒绝原因**: {$reason}"; } return implode("\n", $parts); } /** * 返回成功卡片(更新原卡片) * 飞书卡片回调响应格式: * https://open.feishu.cn/document/feishu-cards/card-callback-communication */ private function successCard(string $title, string $content, ?string $msgToken = null): Response { // 检查内容是否已包含操作时间(如果包含时间戳则不再添加) $hasTime = strpos($content, '操作时间') !== false; $elements = [ [ 'tag' => 'div', 'text' => [ 'content' => $content, 'tag' => 'lark_md', ], ], ]; // 如果没有操作时间,添加 if (!$hasTime) { $elements[] = [ 'tag' => 'div', 'text' => [ 'content' => '_操作时间: ' . date('Y-m-d H:i:s') . '_', 'tag' => 'lark_md', ], ]; } $cardData = [ 'config' => [ 'wide_screen_mode' => false, ], 'header' => [ 'title' => [ 'content' => $title, 'tag' => 'plain_text', ], 'template' => 'green', ], 'elements' => $elements, ]; // 飞书卡片回调的正确响应格式 $response = [ 'toast' => [ 'type' => 'success', 'content' => $title, ], 'card' => [ 'type' => 'raw', 'data' => $cardData, ], ]; return $this->json($response); } /** * 返回错误卡片(更新原卡片显示错误) */ private function errorCard(string $message, ?string $msgToken = null): Response { $cardData = [ 'config' => [ 'wide_screen_mode' => false, ], 'header' => [ 'title' => [ 'content' => '操作失败', 'tag' => 'plain_text', ], 'template' => 'red', ], 'elements' => [ [ 'tag' => 'div', 'text' => [ 'content' => $message, 'tag' => 'lark_md', ], ], ], ]; $response = [ 'toast' => [ 'type' => 'error', 'content' => '操作失败', ], 'card' => [ 'type' => 'raw', 'data' => $cardData, ], ]; return $this->json($response); } /** * 返回 JSON 响应 */ private function json(array $data): Response { $json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); error_log('📤 飞书卡片响应: ' . $json); // 使用原始响应,确保没有任何额外包装 return response($json, 200, [ 'Content-Type' => 'application/json; charset=utf-8', ]); } }