Files
x-server-mobile/backend/app/controller/FeishuCardController.php
T
root 34b294fd48 fix: 添加订单状态检查和友好错误提示
- 检测订单已完成状态,返回友好提示
- 添加infoCard方法显示中性提示
- 优化自审批错误处理
2026-02-28 14:30:35 +08:00

449 lines
15 KiB
PHP

<?php
namespace app\controller;
use Webman\Http\Request;
use Webman\Http\Response;
use app\service\ApprovalService;
use app\model\CompanyFeishuApp;
use app\model\User;
use app\model\UserFeishuApp;
/**
* 飞书消息卡片回调控制器
* 处理飞书卡片的 URL 验证和用户交互
*/
class FeishuCardController extends BaseController
{
/**
* 飞书卡片回调接口
* 处理两种类型的请求:
* 1. url_verification - URL 验证
* 2. card.callback - 卡片按钮点击回调
*/
public function callback(Request $request): Response
{
$body = $request->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('审批单不存在');
}
// 检查订单是否已经处理过
if ($order->status === \app\model\ApprovalOrder::STATUS_APPROVED) {
error_log('⚠️ 订单已通过: order_id=' . $orderId);
return $this->infoCard('该申请已通过', '此申请已经被审批通过,无需重复操作。');
}
if ($order->status === \app\model\ApprovalOrder::STATUS_REJECTED) {
error_log('⚠️ 订单已拒绝: order_id=' . $orderId);
return $this->infoCard('该申请已拒绝', '此申请已经被拒绝,如需重新申请请提交新的申请。');
}
if ($order->status === \app\model\ApprovalOrder::STATUS_CANCELLED) {
error_log('⚠️ 订单已撤销: order_id=' . $orderId);
return $this->infoCard('该申请已撤销', '此申请已经被撤销。');
}
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',
]);
}
}