Laravel 数据库通知聚合与计数更新策略

本文旨在提供一种优化 Laravel 数据库通知的方法,以避免在短时间内发送大量重复或相似的通知。通过实现基于时间窗口的通知聚合策略,可以在特定时间段内将多个相关事件合并为一个通知,并自动更新其计数,从而提升用户体验并减少数据库负担。

在构建现代 Web 应用程序时,通知功能是用户体验的关键组成部分。然而,当系统在短时间内触发大量相似的通知时(例如,多个新帖子匹配了用户的保存搜索),用户可能会感到信息过载。为了解决这一问题,我们可以采用一种策略:在指定的时间窗口内,将相关的通知进行聚合,而不是为每个事件都创建一条新的通知。这种聚合方式通常表现为更新现有通知的计数和内容。

核心策略:基于时间窗口的通知聚合

该策略的核心思想是:当一个新事件触发通知时,首先检查在最近一个预定义的时间窗口内(例如30分钟),是否存在与此事件高度相关的未读通知。

  1. 如果存在:更新该现有通知的计数和内容,并阻止系统创建新的通知记录。
  2. 如果不存在:则创建一个全新的通知。

通过这种方式,用户只会看到一条不断更新的通知,而不是一连串独立的通知,直到超过时间窗口或用户阅读了该通知。

实现方法:修改 toDatabase 方法

Laravel 的通知系统通过 toDatabase 方法定义了通知如何存储到数据库。为了实现通知聚合,我们需要在该方法中加入条件逻辑。关键在于,当成功更新现有通知时,toDatabase 方法应返回 null 或 false,以阻止 Laravel 默认的通知创建行为。

以下是修改后的 toDatabase 方法示例,它实现了基于搜索 ID 和时间窗口的通知聚合:

search = $search;
        $this->post = $post;
    }

    /**
     * 获取通知的投递渠道。
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable): array
    {
        return ['database'];
    }

    /**
     * 获取通知的数据库表示。
     *
     * @param  mixed  $notifiable
     * @return array|null
     */
    public function toDatabase($notifiable): ?array
    {
        // 定义通知聚合的时间窗口(例如30分钟)
        $timeWindowMinutes = 30;

        // 构造当前通知的数据,其中包含用于识别聚合的键(如 'search_id')
        $currentNotificationData = [
            'search_id' => $this->search->id,
            'title' => $this->search->title,
            'parameters' => $this->search->parameters,
            // 如果需要,可以在这里记录触发当前通知的最新帖子ID
            'latest_post_id' => $this->post ? $this->post->id : null, 
        ];

        // 尝试查找在指定时间窗口内,与当前搜索相关的未读通知
        // 1. where('type', self::class) 确保只聚合同类型的通知
        // 2. where('read_at', null) 确保只聚合用户尚未阅读的通知
        // 3. where('data->search_id', $this->search->id) 通过JSON字段精确匹配搜索ID
        // 4. where('created_at', '>=', now()->subMinutes($timeWindowMinutes)) 定义时间窗口
        $existingNotification = $notifiable->notifications()
            ->where('type', self::class)
            ->where('read_at', null) 
            ->where('data->search_id', $this->search->id) 
            ->where('created_at', '>=', now()->subMinutes($timeWindowMinutes))
            ->first();

        if ($existingNotification) {
            // 如果找到现有通知,更新其计数和内容
            $currentCount = $existingNotification->data['count'] ?? 1;
            $newCount = $currentCount + 1;

            // 更新现有通知的数据
            $existingNotification->update([
                'data' => array_merge($existingNotification->data, [
                    'count' => $newCount,
                    'content' => [
                        'en' => "{$newCount} new posts matched with your saved search {$this->search->title} have been posted. Click here to view more.",
                        // 可以添加一个时间戳,表示最后更新时间
                        'last_updated' => now()->toDateTimeString(),
                    ],
                    // 更新最新的帖子ID
                    'latest_post_id' => $this->post ? $this->post->id : null,
                ]),
                'updated_at' => now(), // 手动更新 updated_at 字段
            ]);

            // 返回 null 阻止创建新的通知记录
            return null;
        }

        // 如果没有找到现有通知,则创建新的通知
        return [
            'content' => [
                'en' => "1 new post matched with your saved search {$this->search->title} has been posted. Click here to view more.",
            ],
            'count' => 1,
            // 确保这里返回的数据结构与查询时使用的键(如 'search_id')一致
            'search_id' => $this->search->id, 
            'parameters' => $this->search->parameters,
            'latest_post_id' => $this->post ? $this->post->id : null,
        ];
    }
}

注意事项

  1. return null 的关键作用:当 toDatabase 方法返回 null 或 false 时,Laravel 的通知系统将不会在 notifications 表中创建新的记录。这是实现通知聚合而不创建新通知的核心机制。
  2. 通知类型过滤 (where('type', self::class)):在查询现有通知时,务必通过 where('type', self::class) 过滤通知类型。这确保了我们只聚合相同类型的通知,避免将不同目的的通知混淆。
  3. read_at 状态过滤 (where('read_at', null)):通常情况下,我们只希望聚合用户尚未阅读的通知。一旦用户阅读了通知,即使在时间窗口内,也应该创建一个新的通知,以确保用户不会错过新的信息。
  4. 数据结构设计与查询
    • 在 toDatabase 返回的数组中,应包含一个或多个用于唯一标识聚合通知的键(例如 search_id)。
    • 查询现有通知时,使用 where('data->your_key', $value) 来精确匹配 JSON 字段中的特定键值。确保存储的键名和查询的键名一致。
  5. 手动更新 updated_at:当通过 update() 方法修改现有通知记录时,Laravel 默认不会自动更新 updated_at 字段(因为 MorphMany 关系通常不直接触发模型事件)。因此,建议手动将 updated_at 设置为 now(),以反映通知的实际更新时间。
  6. 内容动态更新:确保通知内容(如 content 字段)能够动态反映最新的计数和状态。在示例中,我们将计数直接嵌入到通知文本中。
  7. 队列处理 (ShouldQueue):对于可能涉及数据库查询和更新的通知逻辑,强烈建议实现 ShouldQueue 接口,将通知的发送和处理推送到队列中,以避免阻塞用户请求。
  8. 聚合粒度:根据业务需求,聚合的粒度可以更细致。例如,除了 search_id,还可以考虑用户的其他偏好设置或更复杂的事件关联规则。

总结

通过在 toDatabase 方法中引入条件逻辑并巧妙利用 return null,我们能够有效地实现 Laravel 数据库通知的聚合与计数更新。这种策略不仅减少了通知的泛滥,提升了用户体验,也降低了数据库的存储压力。在设计通知系统时,灵活运用这种模式,可以根据实际业务场景创建更加智能和用户友好的通知机制。