From b1895be50d53fd8971dd45aaf507e054857a19ed Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 17:53:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=A3=9E=E4=B9=A6?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=80=9A=E7=9F=A5=E5=8D=A1=E7=89=87=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E5=8D=A1=E7=89=87=E5=9B=9E=E8=B0=83=E6=9C=BA?= =?UTF-8?q?=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改动: - 去掉卡片中的备注输入框和表单元素 - 改为三个按钮(通过、拒绝、查看详情)横向排列在一行 - 通过/拒绝按钮使用卡片回调(value)而不是跳转 URL - 新增 FeishuCardController 处理飞书卡片回调 - 添加提示文字告知用户如需备注可去详情页操作 技术细节: - 按钮点击触发 card.action.trigger 事件 - 后端通过 FeishuCardController 处理回调 - 支持 url_verification 和 card.callback 两种事件类型 - 查看详情 URL 添加公司参数支持多租户 Co-Authored-By: Claude Opus 4.6 --- .../app/controller/FeishuCardController.php | 426 ++++++++++++++++++ .../app/service/FeishuNotificationService.php | 78 +++- 2 files changed, 479 insertions(+), 25 deletions(-) create mode 100644 backend/app/controller/FeishuCardController.php diff --git a/backend/app/controller/FeishuCardController.php b/backend/app/controller/FeishuCardController.php new file mode 100644 index 0000000..0df3c91 --- /dev/null +++ b/backend/app/controller/FeishuCardController.php @@ -0,0 +1,426 @@ +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,不用包装 + return new \Webman\Http\Response( + 200, + ['Content-Type' => 'application/json'], + json_encode([ + 'challenge' => $challenge, + ]) + ); + } + + /** + * 处理卡片按钮点击回调 + * 支持两种格式: + * 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) { + return $this->errorCard('审批单不存在'); + } + + // 执行审批通过 + $approvalService->approve($orderId, $user->id, $remark); + + error_log('✅ 审批通过成功: order_id=' . $orderId . ', remark=' . $remark); + return $this->successCard('审批通过', $this->buildApproveContent($user, $order, $remark)); + + } catch (\Exception $e) { + error_log('❌ 审批通过异常: ' . $e->getMessage()); + 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) { + return $this->errorCard('审批单不存在'); + } + + // 执行审批拒绝 + $approvalService->reject($orderId, $user->id, $reason); + + error_log('✅ 审批拒绝成功: order_id=' . $orderId . ', reason=' . $reason); + return $this->successCard('已拒绝', $this->buildRejectContent($user, $order, $reason)); + + } catch (\Exception $e) { + error_log('❌ 审批拒绝异常: ' . $e->getMessage()); + 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 + { + return new \Webman\Http\Response( + 200, + ['Content-Type' => 'application/json'], + json_encode($data) + ); + } +} diff --git a/backend/app/service/FeishuNotificationService.php b/backend/app/service/FeishuNotificationService.php index cdabd01..61ba363 100644 --- a/backend/app/service/FeishuNotificationService.php +++ b/backend/app/service/FeishuNotificationService.php @@ -143,7 +143,7 @@ class FeishuNotificationService return false; } - $card = $this->buildApprovalCard($params); + $card = $this->buildApprovalCard($params, $user->company_id); return $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log('❌ sendApprovalNotification 失败: ' . $e->getMessage()); @@ -185,7 +185,7 @@ class FeishuNotificationService continue; } - $card = $this->buildSystemCard($params); + $card = $this->buildSystemCard($params, $user->company_id); $results[$userId] = $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log("❌ sendSystemNotification 失败: user_id={$userId}, error=" . $e->getMessage()); @@ -229,7 +229,7 @@ class FeishuNotificationService return false; } - $card = $this->buildReminderCard($params); + $card = $this->buildReminderCard($params, $user->company_id); return $this->sendMessage($feishuUserId, $card); } catch (Exception $e) { error_log('❌ sendReminderNotification 失败: ' . $e->getMessage()); @@ -276,7 +276,7 @@ class FeishuNotificationService /** * 构建审批通知卡片 */ - private function buildApprovalCard(array $params): array + private function buildApprovalCard(array $params, int $recipientCompanyId): array { $title = $params['title'] ?? '新的审批申请'; $applicant = $params['applicant'] ?? '未知'; @@ -307,39 +307,58 @@ class FeishuNotificationService ]; } - // 操作按钮区域 - $actions = []; - - // 如果有审批单ID,添加审批操作按钮(使用快速审批页面,无感操作) + // 提示信息(如需备注请去详情页) if ($orderId) { - // 通过按钮(绿色)- 快速审批页面 - $actions[] = [ + $elements[] = [ + 'tag' => 'div', + 'text' => [ + 'content' => "_如需添加备注,请点击「查看详情」进入详情页操作_", + 'tag' => 'lark_md', + ], + ]; + } + + // 按钮区域:三个按钮在一行 + $buttonActions = []; + + // 通过按钮 + if ($orderId) { + $buttonActions[] = [ 'tag' => 'button', 'text' => [ - 'content' => '✓ 通过', + 'content' => '通过', 'tag' => 'plain_text', ], 'type' => 'primary', 'size' => 'medium', - 'url' => "https://xsm.dev.hnxinyouhe.com/approval/quick-action?action=approve&order_id={$orderId}", + 'value' => [ + 'action_type' => 'approve', + 'order_id' => $orderId, + ], ]; - // 拒绝按钮(红色)- 快速审批页面 - $actions[] = [ + // 拒绝按钮 + $buttonActions[] = [ 'tag' => 'button', 'text' => [ - 'content' => '✗ 拒绝', + 'content' => '拒绝', 'tag' => 'plain_text', ], 'type' => 'danger', 'size' => 'medium', - 'url' => "https://xsm.dev.hnxinyouhe.com/approval/quick-action?action=reject&order_id={$orderId}", + 'value' => [ + 'action_type' => 'reject', + 'order_id' => $orderId, + ], ]; } - // 查看详情按钮 + // 查看详情按钮(跳转链接) if ($url) { - $actions[] = [ + $separator = strpos($url, '?') !== false ? '&' : '?'; + $urlWithCompany = $url . $separator . 'company=' . $recipientCompanyId; + + $buttonActions[] = [ 'tag' => 'button', 'text' => [ 'content' => '查看详情', @@ -347,14 +366,15 @@ class FeishuNotificationService ], 'type' => 'default', 'size' => 'medium', - 'url' => $url, + 'url' => $urlWithCompany, ]; } - if (!empty($actions)) { + // 将所有按钮放在一个 action 元素中(会横向排列) + if (!empty($buttonActions)) { $elements[] = [ 'tag' => 'action', - 'actions' => $actions, + 'actions' => $buttonActions, ]; } @@ -376,7 +396,7 @@ class FeishuNotificationService /** * 构建系统通知卡片 */ - private function buildSystemCard(array $params): array + private function buildSystemCard(array $params, int $recipientCompanyId): array { $title = $params['title'] ?? '系统通知'; $content = $params['content'] ?? ''; @@ -395,6 +415,10 @@ class FeishuNotificationService } if ($url) { + // 在 URL 后面添加公司参数 + $separator = strpos($url, '?') !== false ? '&' : '?'; + $urlWithCompany = $url . $separator . 'company=' . $recipientCompanyId; + $elements[] = [ 'tag' => 'action', 'actions' => [ @@ -405,7 +429,7 @@ class FeishuNotificationService 'tag' => 'plain_text', ], 'type' => 'default', - 'url' => $url, + 'url' => $urlWithCompany, ], ], ]; @@ -429,7 +453,7 @@ class FeishuNotificationService /** * 构建提醒通知卡片 */ - private function buildReminderCard(array $params): array + private function buildReminderCard(array $params, int $recipientCompanyId): array { $title = $params['title'] ?? '提醒'; $content = $params['content'] ?? ''; @@ -465,6 +489,10 @@ class FeishuNotificationService } if ($url) { + // 在 URL 后面添加公司参数 + $separator = strpos($url, '?') !== false ? '&' : '?'; + $urlWithCompany = $url . $separator . 'company=' . $recipientCompanyId; + $elements[] = [ 'tag' => 'action', 'actions' => [ @@ -475,7 +503,7 @@ class FeishuNotificationService 'tag' => 'plain_text', ], 'type' => 'primary', - 'url' => $url, + 'url' => $urlWithCompany, ], ], ];