返回

Laravel自定义通知无法获知API结果?同步获取3妙招

php

Laravel 自定义通知渠道:获取 API 响应的难题与解法

用 Laravel 的通知系统给用户发个短信验证码或者邮件?小事一桩。但如果你用的是自定义通知渠道,比如对接了第三方的短信或邮件服务商 API,事情就可能变得有点棘手。特别是,你想知道这条通知到底发出去了没有,API 返回了成功还是失败?这信息还得马上告诉用户。

问题来了:自定义通知渠道,咋拿到 API 返回结果?

假设你已经搞定了一个自定义的通知渠道,它会调用外部 API 来发送消息。现在,在你的控制器或者服务里,你可能会这样触发通知:

use App\Notifications\SendVerificationCode;
use Illuminate\Support\Facades\Notification;

// ... 其他代码 ...

$otp = "123456"; // 只是示例
$emailAddress = "user@example.com";
$phoneNumber = "+1234567890"; // 确保格式符合你的服务商要求

$notification = new SendVerificationCode([
    "otp" => $otp,
]);

Notification::route('mail', $emailAddress)
   ->route('mobile_number', $phoneNumber) // 'mobile_number' 是你自定义渠道的路由标识符
   ->notify($notification);

// 问题就在这里:`notify()` 方法执行完,啥也不返回。
// 我怎么知道邮件、短信发送成功了没?API 给我啥回应了?

上面的代码片段展示了使用按需通知 (On-Demand Notifications) 的方式。这种方式非常方便,因为它不需要用户模型必须 use Notifiable trait。但麻烦也随之而来:notify() 方法设计上就是“发出去就不管了”,它本身并不返回任何来自渠道的执行结果。

有人可能会想:“我在 SendVerificationCode 通知类里定义个公共属性,然后在自定义渠道的 send 方法里把 API 响应塞回这个属性不就行了?”

// SendVerificationCode.php
namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Channels\CustomSmsChannel; // 你的自定义渠道类

class SendVerificationCode extends Notification
{
    use Queueable;

    public array $parameters;
    public ?array $apiResponses = null; // 尝试用这个属性来存结果

    public function __construct(array $parameters)
    {
        $this->parameters = $parameters;
    }

    public function via($notifiable)
    {
        // 定义要通过哪些渠道发送
        // 假设 'mail' 是 Laravel 自带的, 'mobile_number' 是你的自定义短信渠道标识符
        return ['mail', CustomSmsChannel::class];
    }

    // ... toMail 方法等 ...

    // 你可能还定义了特定于自定义渠道的方法,比如 toCustomSms
    public function toCustomSms($notifiable)
    {
        // 返回构建 API 请求所需的数据
        return [
            'to' => $notifiable->routes['mobile_number'], // 从 on-demand 获取
            'message' => "您的验证码是: " . $this->parameters['otp'],
        ];
    }
}

// CustomSmsChannel.php
namespace App\Channels;

use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Http; // 假设使用 Http Facade

class CustomSmsChannel
{
    public function send($notifiable, Notification $notification)
    {
        // 检查通知类是否有 toCustomSms 方法
        if (! method_exists($notification, 'toCustomSms')) {
            // 或者抛出异常,或者记录日志
            return;
        }

        $data = $notification->toCustomSms($notifiable);
        $apiKey = config('services.sms_provider.key');

        try {
            $response = Http::withHeaders([
                'Authorization' => 'Bearer ' . $apiKey,
            ])->post('https://api.sms-provider.com/send', [
                'recipient' => $data['to'],
                'text' => $data['message'],
            ]);

            // 关键:尝试把结果存回通知对象
            if (property_exists($notification, 'apiResponses')) {
                // 注意:这里修改的是传入的 $notification 对象
                $notification->apiResponses['sms'] = [
                    'status' => $response->successful() ? 'success' : 'failed',
                    'body' => $response->json() ?? $response->body(), // 获取响应体
                    'http_status' => $response->status(),
                ];
            }

        } catch (\Exception $e) {
            // 处理异常情况,例如网络错误、服务商 API 宕机等
            if (property_exists($notification, 'apiResponses')) {
                 $notification->apiResponses['sms'] = [
                    'status' => 'error',
                    'message' => $e->getMessage(),
                 ];
            }
            // 别忘了记录日志或采取其他错误处理措施
             \Log::error('SMS sending failed: ' . $e->getMessage());
        }
    }
}

然后你试着在调用 notify() 后访问 $notification->apiResponses

// ... 接上面调用 notify() 的代码 ...
$responses = $notification->apiResponses;
dump($responses); // 输出 null 或者初始值,根本没变!

为什么会这样?按需通知内部用的是 Illuminate\Notifications\AnonymousNotifiable 类。当你调用 notify() 时,Laravel 可能会(尤其是在队列中处理时)克隆 (clone) 通知对象,或者处理过程中的对象实例跟你最初创建的 $notification 可能不是同一个引用。因此,你在渠道 send 方法里对 $notification 对象的修改,并不会反映到你代码作用域里的那个原始 $notification 实例上。

刨根问底:为什么拿不到返回值?

核心原因有两个:

  1. Notification::notify() 的设计哲学: 这个方法主要负责将通知任务分发给一个或多个渠道。它的设计目标是触发发送动作,而不是等待并收集所有渠道(可能是异步的)的执行结果。返回 void 很符合这种“发射后不管”的模式。
  2. AnonymousNotifiable 的无状态性: 对于常规通知(给数据库里的 User 模型发通知),你可以把结果存回 User 模型或者相关联的记录。但 AnonymousNotifiable 是临时的、无状态的,它没有持久化的“地方”让你方便地挂载返回结果。直接修改传入通知对象的属性在这种场景下往往因为对象实例引用的变化而失效。

一些人提到用缓存、Session,但这感觉像是“打补丁”,不够优雅,而且在并发请求下可能引入状态管理问题。用通知事件(NotificationSentNotificationFailed)确实可以捕获发送结果,但那是另一个流程了,它更适合做后续的、解耦的响应(比如记录日志、更新统计),无法满足在 notify() 调用后 立即 在当前请求流程中根据发送结果做判断的需求。比如,给用户返回“验证码已发送”还是“发送失败,请重试”。

峰回路转:解决思路来了

既然直接的路走不通,我们就得绕一下。下面提供几种思路,它们的核心都是想办法在调用 notify() 之后,能访问到由自定义渠道在执行 API 调用后记录下来的结果。

思路一:改造通知类与渠道,引入结果容器(推荐)

这个方法的核心是:不依赖修改 传入 send 方法的那个 $notification 实例本身,而是利用 PHP 的对象引用特性,让通知类包含一个专门用于存储结果的对象或数组,并且这个结果容器在调用 notify() 前后是同一个实例。

  1. 修改通知类 (SendVerificationCode) :

    • 添加一个公共属性,但这次它的值是一个对象或者一个由 ArrayObject 包装的数组。这样即使通知对象本身被克隆,我们操作的是这个容器对象的内部状态。
    • 或者,更简单直接的方式是,这个属性直接用来存结果数组,但在渠道里不是直接赋值 null,而是操作数组元素。我们主要寄希望于在非队列、同步处理的场景下,对象引用可能保持不变。
    // SendVerificationCode.php
    namespace App\Notifications;
    
    use Illuminate\Bus\Queueable;
    use Illuminate\Notifications\Notification;
    use App\Channels\CustomSmsChannel;
    use ArrayObject; // 使用 ArrayObject 来确保引用传递
    
    class SendVerificationCode extends Notification
    {
        use Queueable;
    
        public array $parameters;
        // 使用 ArrayObject 或者一个简单的 stdClass 对象来持有结果
        public ArrayObject $results; // 或者 public \stdClass $results;
    
        public function __construct(array $parameters)
        {
            $this->parameters = $parameters;
            // 初始化结果容器
            $this->results = new ArrayObject(); // 或者 $this->results = new \stdClass();
        }
    
        public function via($notifiable)
        {
            return [/* 'mail', */ CustomSmsChannel::class]; // 简化示例,只留自定义渠道
        }
    
        public function toCustomSms($notifiable)
        {
            // 获取路由信息时要做健壮性检查
            $recipient = data_get($notifiable->routes, 'mobile_number');
            if (!$recipient) {
                // 最好能记录日志或处理这种无法获取接收者的情况
                return null; // 返回 null 或空数组,让 channel 知道无法发送
            }
            return [
                'to' => $recipient,
                'message' => "您的验证码是: " . $this->parameters['otp'],
            ];
        }
    
        // 如果你还需要其他渠道,比如 mail,也要添加对应的方法
        // public function toMail($notifiable) { ... }
    }
    
  2. 修改自定义渠道 (CustomSmsChannel) :

    • send 方法中,获取通知对象的 $results 属性(现在是 ArrayObjectstdClass)。
    • 将 API 调用结果存入这个容器对象的属性或数组键中。
    // CustomSmsChannel.php
    namespace App\Channels;
    
    use Illuminate\Notifications\Notification;
    use Illuminate\Support\Facades\Http;
    use Illuminate\Support\Facades\Log;
    
    class CustomSmsChannel
    {
        public function send($notifiable, Notification $notification)
        {
            // 确保通知类是你期望的类型,并且有结果容器
            if (! $notification instanceof \App\Notifications\SendVerificationCode || !isset($notification->results)) {
                Log::warning('Notification object missing results container or is wrong type.');
                return;
            }
    
            // 再次检查是否有 toCustomSms 方法
            if (! method_exists($notification, 'toCustomSms')) {
                 Log::warning('Method toCustomSms not found on notification.');
                 return;
            }
    
            $data = $notification->toCustomSms($notifiable);
    
            // 如果 toCustomSms 返回 null 或 false,表示数据准备失败
            if (!$data) {
                // 将准备失败的信息也记录到 results 中
                $notification->results['sms'] = [ // 使用方括号语法操作 ArrayObject
                    'status' => 'error',
                    'message' => 'Failed to prepare SMS data (e.g., missing recipient).',
                ];
                // 或者 $notification->results->sms = ... 如果用的是 stdClass
                 Log::error('SMS sending aborted: Failed to prepare data.');
                return;
            }
    
            $apiKey = config('services.sms_provider.key');
            $apiUrl = config('services.sms_provider.url'); // 建议把 URL 也放配置里
    
            try {
                $response = Http::withHeaders([
                    // 按需添加 'Accept' => 'application/json''Authorization' => 'Bearer ' . $apiKey,
                ])->post($apiUrl, [
                    'recipient' => $data['to'],
                    'text' => $data['message'],
                    // 其他服务商可能需要的参数
                ]);
    
                // 将 API 响应存入结果容器
                $notification->results['sms'] = [ // 使用方括号语法
                    'status' => $response->successful() ? 'success' : 'failed',
                    'body' => $response->json() ?? $response->body(),
                    'http_status' => $response->status(),
                ];
                 // 或者 $notification->results->sms = [...] 如果用 stdClass
    
            } catch (\Illuminate\Http\Client\ConnectionException $e) {
                 // 网络连接错误
                 $notification->results['sms'] = [
                     'status' => 'error',
                     'message' => 'Connection error: ' . $e->getMessage(),
                 ];
                 Log::error('SMS sending failed - Connection error: ' . $e->getMessage());
             } catch (\Exception $e) {
                 // 其他未知异常
                 $notification->results['sms'] = [
                     'status' => 'error',
                     'message' => 'General error: ' . $e->getMessage(),
                 ];
                 Log::error('SMS sending failed - General error: ' . $e->getMessage());
             }
        }
    }
    
  3. 调用代码

    use App\Notifications\SendVerificationCode;
    use Illuminate\Support\Facades\Notification;
    
    // ...
    
    $notification = new SendVerificationCode(["otp" => "123456"]);
    
    Notification::route('mobile_number', '+1234567890')
               // 如果有其他渠道,也加上 ->route(...)
               ->notifyNow($notification); // 注意:使用 notifyNow() 强制同步发送
    
    // 现在可以访问 $notification->results 了
    $smsResult = $notification->results['sms'] ?? null; // 使用方括号访问 ArrayObject 元素
    // 或者 $smsResult = $notification->results->sms ?? null; // 如果用 stdClass
    
    if ($smsResult && $smsResult['status'] === 'success') {
        // 发送成功逻辑
        return response()->json(['message' => '验证码已发送!']);
    } else {
        // 发送失败或出错逻辑
        $errorMessage = $smsResult['message'] ?? '未知错误';
        // 考虑记录详细的 $smsResult['body'] 或 $smsResult['http_status']
         Log::error('OTP SMS failed', ['result' => $smsResult]);
        return response()->json(['message' => '验证码发送失败: ' . $errorMessage], 500);
    }
    

    关键点: 使用 notifyNow() 而不是 notify()notifyNow() 会强制同步执行所有通知渠道的 send 方法,而不是将它们推送到队列(即使通知类使用了 ShouldQueue trait)。这对于需要即时反馈的场景(如 OTP 发送)至关重要。如果你的通知确实需要排队异步处理,那这个“即时获取结果”的需求本身就和异步处理相悖,需要重新考虑流程设计(比如轮询状态、WebSocket 推送结果等)。

    安全建议:

    • API 密钥 ($apiKey) 不要硬编码,务必放在 .env 文件并通过 config() 读取。
    • 考虑对第三方 API 的调用添加超时设置 (Http::timeout(10)->...)。
    • 对 API 返回的错误进行更细致的处理和记录,不要直接暴露敏感错误信息给前端用户。

    进阶技巧:

    • 如果一个通知通过多个自定义渠道发送,可以在 results 容器里用不同的键名(比如 'sms', 'push') 来区分存储各自的结果。
    • 可以在 results 中不仅存状态,也存一些关键的响应数据,比如服务商返回的消息 ID。

思路二:利用回调函数或闭包

可以在创建通知对象时,传入一个回调函数(闭包),自定义渠道在拿到 API 响应后调用这个回调。

  1. 修改通知类 (SendVerificationCode) :

    • 添加一个构造函数参数来接收回调。
    • 添加一个公共属性存储这个回调。
    // SendVerificationCode.php
    namespace App\Notifications;
    
    use Illuminate\Notifications\Notification;
    use Closure; // 引入 Closure 类型提示
    
    class SendVerificationCode extends Notification
    {
        public array $parameters;
        public ?Closure $resultCallback = null; // 存储回调函数
    
        // 构造函数接收回调
        public function __construct(array $parameters, ?Closure $callback = null)
        {
            $this->parameters = $parameters;
            $this->resultCallback = $callback;
        }
    
        // ... via(), toCustomSms() 等方法保持不变 ...
    }
    
  2. 修改自定义渠道 (CustomSmsChannel) :

    • send 方法获取 API 响应后,检查通知对象是否有回调函数,如果有就调用它,把结果传进去。
    // CustomSmsChannel.php
    namespace App\Channels;
    
    use Illuminate\Notifications\Notification;
    // ... 其他 use ...
    
    class CustomSmsChannel
    {
        public function send($notifiable, Notification $notification)
        {
             // ... 获取 $data, $apiKey, $apiUrl 的代码 ...
    
            $result = []; // 用来存储结果的数组
            try {
                $response = Http::withHeaders([...])->post(...);
                $result = [
                    'channel' => 'sms', // 标识是哪个渠道的结果
                    'status' => $response->successful() ? 'success' : 'failed',
                    'body' => $response->json() ?? $response->body(),
                    'http_status' => $response->status(),
                ];
            } catch (\Exception $e) {
                 $result = [
                    'channel' => 'sms',
                    'status' => 'error',
                    'message' => $e->getMessage(),
                 ];
                 Log::error('SMS sending failed: ' . $e->getMessage());
            }
    
            // 检查并调用回调
            if ($notification instanceof \App\Notifications\SendVerificationCode && is_callable($notification->resultCallback)) {
                 // 调用回调,把结果传出去
                 call_user_func($notification->resultCallback, $result);
            }
        }
    }
    
  3. 调用代码

    use App\Notifications\SendVerificationCode;
    use Illuminate\Support\Facades\Notification;
    
    // ...
    
    $apiResponses = []; // 用于收集回调结果的数组
    
    $notification = new SendVerificationCode(
        ["otp" => "123456"],
        // 定义回调函数
        function(array $result) use (&$apiResponses) {
             // $result 包含了渠道传回的数据 ('channel', 'status', 'body', ...)
             // 使用引用传递 (use (&$apiResponses)) 来修改外部变量
            $apiResponses[$result['channel']] = $result;
        }
    );
    
    Notification::route('mobile_number', '+1234567890')
               ->notifyNow($notification); // 同样建议用 notifyNow()
    
    // 检查收集到的结果
    $smsResult = $apiResponses['sms'] ?? null;
    
    if ($smsResult && $smsResult['status'] === 'success') {
        // 成功...
        return response()->json(['message' => '验证码已发送!']);
    } else {
        // 失败...
        $errorMessage = $smsResult['message'] ?? '发送失败或未收到回调';
        Log::error('OTP SMS failed or callback not received', ['result' => $smsResult]);
        return response()->json(['message' => '验证码发送失败: ' . $errorMessage], 500);
    }
    

    这种方式代码耦合度相对低一些,渠道只负责调用回调,不关心结果具体怎么处理。但也依赖于 notifyNow() 同步执行。

    安全建议: 同思路一。回调函数内部的代码也需要注意错误处理。

    进阶技巧: 回调函数可以设计得更复杂,比如接收 $channelName$result 两个参数,使得调用方能更清晰地处理来自不同渠道的回调。

思路三:利用单例服务或上下文对象

创建一个在请求生命周期内存在的单例服务,或者一个简单的上下文对象,通过依赖注入或 Facade 访问它。自定义渠道将结果写入这个服务,调用代码在 notifyNow() 之后从服务中读取结果。

  1. 创建结果服务

    // app/Services/NotificationResultStore.php
    namespace App\Services;
    
    class NotificationResultStore
    {
        protected array $results = [];
    
        public function record(string $channel, array $result)
        {
            $this->results[$channel] = $result;
        }
    
        public function get(string $channel): ?array
        {
            return $this->results[$channel] ?? null;
        }
    
        public function all(): array
        {
            return $this->results;
        }
    
        public function clear()
        {
             // 可选:请求结束后清理,或者每次调用前清理
            $this->results = [];
        }
    }
    
  2. 注册为单例

    // app/Providers/AppServiceProvider.php
    namespace App\Providers;
    
    use Illuminate\Support\ServiceProvider;
    use App\Services\NotificationResultStore;
    
    class AppServiceProvider extends ServiceProvider
    {
        public function register()
        {
            // 注册为单例,保证同一次请求中获取的是同一个实例
            $this->app->singleton(NotificationResultStore::class, function ($app) {
                return new NotificationResultStore();
            });
        }
        // ...
    }
    
  3. 修改自定义渠道

    // CustomSmsChannel.php
    namespace App\Channels;
    
    use Illuminate\Notifications\Notification;
    use App\Services\NotificationResultStore; // 引入服务
    // ... 其他 use ...
    
    class CustomSmsChannel
    {
        protected NotificationResultStore $resultStore;
    
        // 通过构造函数注入服务
        public function __construct(NotificationResultStore $resultStore)
        {
            $this->resultStore = $resultStore;
        }
    
        public function send($notifiable, Notification $notification)
        {
            // ... API 调用和获取 $result 数组的代码 ...
    
            // 将结果记录到服务中
            $this->resultStore->record('sms', $result); // 'sms' 是渠道标识
        }
    }
    
  4. 调用代码

    use App\Notifications\SendVerificationCode;
    use Illuminate\Support\Facades\Notification;
    use App\Services\NotificationResultStore; // 引入服务
    
    // ...
    
    // 获取服务实例
    $resultStore = app(NotificationResultStore::class);
    $resultStore->clear(); // (可选)确保从干净的状态开始
    
    $notification = new SendVerificationCode(["otp" => "123456"]);
    
    Notification::route('mobile_number', '+1234567890')
               ->notifyNow($notification); // 必须是 notifyNow()
    
    // 从服务中获取结果
    $smsResult = $resultStore->get('sms');
    
    if ($smsResult && $smsResult['status'] === 'success') {
       // ... 成功处理
    } else {
       // ... 失败处理
    }
    

    这种方式将状态管理完全外置,通知类和渠道类更干净。同样,依赖 notifyNow()

    安全建议: 同上。确保 NotificationResultStore 只在需要的作用域内(如请求作用域)是单例,避免跨请求数据污染(Laravel 默认的 singleton 绑定通常能满足请求作用域)。

    进阶技巧: 可以为 NotificationResultStore 增加更多功能,比如设置唯一请求 ID 来隔离结果,使其在 Octane 等环境下更健壮。

选哪个好?

对于“立即需要知道发送结果”这个特定需求,思路一(改造通知类 + ArrayObject/stdClass + notifyNow()) 是最直接也相对简单的方案 。它把结果和通知本身关联起来,逻辑上比较清晰。

思路二(回调) 提供了更好的解耦,但代码稍微复杂一点点。

思路三(单例服务) 是最“企业级”的方案,状态管理最清晰,但也引入了额外的服务类。

记住,这三种推荐的方案都强依赖于 notifyNow() 来实现同步执行和即时反馈。如果你的业务场景允许或者需要异步处理通知(例如,发送大量通知,不希望阻塞用户请求),那么你就不能期望在调用 notify() 之后立刻拿到结果。这种情况下,你需要调整产品逻辑,可能的方法包括:

  • 前端轮询一个查询状态的接口。
  • 后端通过 WebSocket 或 Server-Sent Events (SSE) 把结果推送给前端。
  • 使用 Laravel 的通知事件(NotificationSent/NotificationFailed)触发后续的异步处理逻辑,更新数据库状态,前端再根据数据库状态展示信息。

选择哪种方案,最终取决于你的具体需求、团队的技术偏好以及对同步/异步处理的取舍。