2025年4月17日 星期四

使用 Laravel、Redis 和 LINE Messaging API 建立客服系統

這個系統的目標是讓 LINE 的訊息可以透過網頁介面讓客服人員回覆。

好的,這是一個從 LINE 用戶發送訊息開始,到客服人員在網頁上回覆的完整步驟流程:


打造高併發 LINE 客服系統:設計與實踐

前言:以技術解決即時通訊挑戰

在高流量即時通訊場景下,系統的穩定性與響應速度是企業與用戶互動的核心挑戰。在這篇文章中,我將分享我設計並實作的一套 LINE 客服系統,展示如何透過 Laravel 生態系、Redis、WebSocket 與異步佇列,打造一個兼顧高併發處理、可擴展性與即時性的解決方案。這套系統不僅滿足了用戶對即時溝通的期待,也為客服團隊提供了流暢的操作體驗。

這篇文章將從架構設計、技術選型、核心實作到部署運維,全面剖析我的技術思維與實務能力,希望能為面試官或技術同儕提供有價值的參考。

系統架構:高效訊息流的設計理念

LINE 客服系統的核心目標,是確保從用戶發送訊息到客服回覆的整個流程,在高併發情境下仍能保持低延遲與高穩定性。以下是系統架構的 Mermaid 圖表,清晰呈現各組件間的訊息流動:

graph TD
    subgraph LINE 用戶端
        LINEUser[LINE 用戶] --> |發送訊息| LINEApp[LINE App]
    end
    subgraph LINE Platform
        LINEApp --> |Webhook HTTP POST| LINEPlatform(LINE Platform)
        LINEPlatform --> |HTTP POST /line/webhook| LaravelApp(Laravel 應用程式)
    end
    subgraph Laravel 應用程式
        LaravelApp --> LineWebhookController(LineWebhookController)
        LineWebhookController --> |驗證簽名、解析事件| LINEPlatform
        LineWebhookController --> |快速回應 200 OK| LINEPlatform
        LineWebhookController --> |異步處理| ProcessLineWebhookEventJob(ProcessLineWebhookEvent Job)
        subgraph Laravel Queues
            ProcessLineWebhookEventJob --> QueueWorker(佇列工作者)
        end
        QueueWorker --> |儲存訊息<br>記錄活躍用戶| Redis(Redis)
        QueueWorker --> |廣播新訊息| LaravelEchoServer(Laravel Echo Server)
        ChatController(ChatController)
        ChatController --> |獲取活躍用戶<br>獲取聊天記錄| Redis
        ChatController --> |發送回覆| LINEApi(LINE Messaging API)
        ChatController --> |儲存回覆| Redis
        ChatController --> |廣播客服回覆| LaravelEchoServer
    end
    subgraph Redis
        Redis[(Redis)]
    end
    subgraph WebSocket 伺服器
        LaravelEchoServer(Laravel Echo Server)
    end
    subgraph 客服網頁介面
        AdminBrowser(客服瀏覽器) --> |訪問 /chat| LaravelApp
        AdminBrowser --> |API 請求| ChatController
        AdminBrowser --> |WebSocket 連線| LaravelEchoServer
        LaravelEchoServer --> |即時推送| AdminBrowser
    end
    subgraph 外部服務
        LINEApi --> |推送訊息| LINEUser
    end
    style LINEUser fill:#a2d9ce
    style LINEApp fill:#a2d9ce
    style LINEPlatform fill:#a2d9ce
    style LaravelApp fill:#cceeff
    style LineWebhookController fill:#b3e0ff
    style ProcessLineWebhookEventJob fill:#e6f7ff
    style QueueWorker fill:#e6f7ff
    style Redis fill:#ffcc99
    style LaravelEchoServer fill:#cceeff
    style ChatController fill:#b3e0ff
    style AdminBrowser fill:#ffffcc
    style LINEApi fill:#a2d9ce
這套架構以解耦與異步為核心,透過 Laravel 佇列、Redis 高速緩存與 WebSocket 即時通訊,確保訊息流在高負載下仍能高效運轉。

### 核心設計策略:性能、穩定性與即時性的平衡

在設計這套系統時,我聚焦於高併發場景下的性能優化與穩定性保障,採用以下三大策略:

**1. Laravel 佇列:異步處理與流量削峰**
* **挑戰:** LINE Platform 對 Webhook 響應時間要求嚴格(通常需在數秒內回應)。若在主線程同步處理訊息(如寫入資料庫、調用 API),高峰期可能導致超時或事件重發,影響服務可用性。
* **解決方案:** 利用 Laravel 內建的佇列系統,實現關鍵業務邏輯的異步化。`LineWebhookController` 在接收到 LINE 的 Webhook 請求後,僅負責執行輕量級的簽名驗證與事件解析,隨後立即將完整的事件數據**分派至指定佇列**(`ProcessLineWebhookEvent::dispatch($event)->onQueue('line_webhook')`),並迅速返回 `200 OK` 響應。
* **效益:** 這種解耦策略確保了 Webhook 響應的極速,有效避免了 LINE 平台的超時懲罰。後台的「佇列工作者 (Queue Workers)」則能獨立於主 Web 服務器運行,可根據系統負載彈性地水平擴展,並行處理大量訊息,實現流量削峰,從根本上提升系統在高併發下的穩定性與吞吐量。

**2. Redis:記憶體級別的數據存取**
* **挑戰:** 聊天系統需頻繁讀寫用戶訊息與活躍狀態,尤其是在高併發查詢活躍用戶列表和載入聊天記錄時,傳統關聯式資料庫(如 MySQL)在高併發下容易成為 I/O 瓶頸。
* **解決方案:** 我選用 Redis 作為核心的訊息儲存與活躍用戶管理介質,充分利用其記憶體級別的超高速讀寫特性。
    * **訊息歷史:** 每個用戶的聊天記錄以 JSON 格式,被高效地儲存在 Redis 的 **List** 數據結構中(`RPUSH user:{userId}:messages`)。List 結構天然支持時間序列數據的追加與範圍查詢,非常適合聊天記錄的存取。
    * **活躍用戶:** 使用 Redis 的 **Set** 數據結構來追蹤當前有互動的用戶(`SADD active_users`)。Set 提供了快速的成員判斷(`SISMEMBER`)和獲取所有成員(`SMEMBERS`)操作,且天然保證成員的唯一性。
* **效益:** Redis 的記憶體級別讀寫速度確保了客服網頁能極速載入聊天記錄和用戶列表,極大提升了客服人員的操作流暢度與響應速度。同時,這顯著減輕了主資料庫的負載,使其能夠專注於更持久化和複雜的數據儲存,保證整體系統的性能表現。

**3. WebSocket:實現毫秒級即時通訊**
* **挑戰:** 傳統 HTTP 輪詢方式會產生大量冗餘請求,增加伺服器負擔,且無法提供真正即時的訊息更新(存在數秒延遲)。這會嚴重影響客服人員的工作效率和客戶的即時溝通體驗。
* **解決方案:** 我整合了 Laravel Websockets(作為 Pusher 協議的本地替代方案),建立基於 WebSocket 的即時雙向通訊機制。
    * **訊息推送:** 當有新的 LINE 用戶訊息(由 `Queue Worker` 處理完畢後)或客服人員發送回覆時,後端會觸發 `NewLineMessage` 廣播事件。
    * **私有頻道:** 訊息會被推送到針對特定用戶的**私有頻道**(`chat.{userId}`),確保訊息的精準推送和數據安全,只有相關客服人員(或其他授權客戶端)能接收到該用戶的更新。
    * **前端監聽:** 客服網頁前端使用 Laravel Echo 庫(基於 Pusher.js)監聽這些頻道。一旦接收到新訊息事件,便立即在聊天介面中顯示,無需手動刷新,實現毫秒級的即時更新。
* **效益:** WebSocket 建立持久連線,大幅降低了不必要的 HTTP 請求數量,顯著減少伺服器負載,並為客服人員提供了無縫、零延遲的即時聊天體驗,極大提升了客服效率和客戶滿意度。

### 技術選型與實作亮點

我選擇 Laravel 生態系作為核心框架,結合業界成熟的工具,確保開發效率與系統穩定性:

* **Laravel Framework:** 其強大的 MVC 架構、Eloquent ORM、內建佇列與廣播系統,為系統提供了穩固、高效且可維護的開發基礎。我充分利用了其開箱即用的特性,加速了專案進程。
* **Predis / GuzzleHttp:** `Predis` 作為 Laravel 推薦的 Redis 客戶端,實現了高效且穩定的 Redis 操作;`GuzzleHttp` 作為 PHP 最流行的 HTTP 客戶端,確保了 LINE Messaging API 調用的穩定性、可配置性與錯誤處理。
* **Laravel Websockets:** 作為 Pusher 的本地替代方案,它讓我在無需依賴第三方服務的情況下,實現了高併發的 WebSocket 連線管理,降低了營運成本。
* **LINE Bot SDK (PHP):** 官方 SDK 抽象了 LINE Webhook 簽名驗證、事件解析和訊息發送的複雜度,使開發者能專注於核心業務邏輯而非底層協議細節,大大提升了開發效率。
* **Vue.js:** 搭配 Laravel UI,我快速構建了響應式且互動性強的客服網頁介面。Vue.js 的組件化開發模式與數據響應式特性,讓界面開發更為直觀和高效。

以下是核心程式碼的實作細節,附有詳細註釋,展示我的技術深度與對 Laravel 生態的熟練運用:

#### 1. `LineWebhookController`

負責接收 LINE Webhook 請求,驗證簽名後分派事件至佇列,確保快速響應。

```php
<?php
// app/Http/Controllers/LineWebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use LINE\LINEBot;
use LINE\LINEBot\HTTPClient\CurlHTTPClient;
use App\Jobs\ProcessLineWebhookEvent;
use Illuminate\Support\Facades\Log; // 引入 Log facade
use LINE\LINEBot\Exception\InvalidSignatureException; // 引入 LINE Bot 異常類別
use LINE\LINEBot\Exception\UnknownEventTypeException;
use LINE\LINEBot\Exception\UnknownMessageTypeException;
use LINE\LINEBot\Exception\HttpRequestException;

class LineWebhookController extends Controller
{
    /**
     * 處理 LINE Webhook 請求
     * 功能:接收 LINE 平台發送的 Webhook 事件,驗證簽名後將事件分派至佇列,並立即返回 200 OK 以符合 LINE 的響應時間要求
     */
    public function handle(Request $request)
    {
        // 從 config/services.php 獲取 LINE Channel Access Token 和 Channel Secret
        // 請確保在 config/services.php 中有 'line' 相關設定
        // 例如:
        // 'line' => [
        //     'channel_access_token' => env('LINE_CHANNEL_ACCESS_TOKEN'),
        //     'channel_secret' => env('LINE_CHANNEL_SECRET'),
        // ],
        $httpClient = new CurlHTTPClient(config('services.line.channel_access_token'));
        $bot = new LINEBot($httpClient, ['channelSecret' => config('services.line.channel_secret')]);

        // 驗證 LINE Webhook 簽名,確保請求來源可信
        $signature = $request->header('X-Line-Signature');
        $body = $request->getContent(); // 獲取原始請求體

        if (empty($signature)) {
            Log::error('X-Line-Signature not found in Webhook request.');
            return response()->json(['error' => '無效簽名或簽名缺失'], 400);
        }

        if (empty($body)) {
            Log::error('Request body is empty in Webhook request.');
            return response()->json(['error' => '請求體為空'], 400);
        }

        try {
            // 解析 Webhook 事件並分派到佇列進行異步處理
            $events = $bot->parseEventRequest($body, $signature);
        } catch (InvalidSignatureException $e) {
            Log::error('Invalid signature for LINE Webhook: ' . $e->getMessage());
            return response()->json(['error' => '簽名驗證失敗'], 400);
        } catch (UnknownEventTypeException $e) {
            Log::warning('Unknown event type received from LINE: ' . $e->getMessage());
            return response()->json(['error' => '未知事件類型'], 200); // 對未知事件類型也應返回 200 OK
        } catch (UnknownMessageTypeException $e) {
            Log::warning('Unknown message type received from LINE: ' . $e->getMessage());
            return response()->json(['error' => '未知訊息類型'], 200); // 對未知訊息類型也應返回 200 OK
        } catch (HttpRequestException $e) {
            Log::error('HTTP request error during LINE Webhook parsing: ' . $e->getMessage());
            return response()->json(['error' => 'HTTP 請求錯誤'], 500);
        } catch (\Exception $e) {
            Log::error('An unexpected error occurred during LINE Webhook handling: ' . $e->getMessage());
            return response()->json(['error' => '伺服器內部錯誤'], 500);
        }

        foreach ($events as $event) {
            // 將事件分派到 'line_webhook' 佇列
            ProcessLineWebhookEvent::dispatch($event)->onQueue('line_webhook');
        }

        // 立即返回 200 OK,避免 LINE 平台超時
        return response()->json(['status' => 'OK'], 200);
    }
}

2. ProcessLineWebhookEvent Job

異步處理 LINE 事件,儲存訊息至 Redis,更新活躍用戶,並觸發廣播事件。

<?php
// app/Jobs/ProcessLineWebhookEvent.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Redis;
use App\Events\NewLineMessage;
use LINE\LINEBot\Event\TextMessage;
use LINE\LINEBot\Event\BaseEvent;
use Illuminate\Queue\SerializesModels;

class ProcessLineWebhookEvent implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    protected $event;

    /**
     * 建構子:接收 LINE 事件
     * @param BaseEvent $event LINE Bot 事件物件
     */
    public function __construct(BaseEvent $event)
    {
        $this->event = $event;
    }

    /**
     * 處理佇列中的 LINE 事件
     * 功能:儲存訊息至 Redis,更新活躍用戶,廣播新訊息事件
     */
    public function handle()
    {
        // 僅處理文字訊息事件,可依需求擴展處理其他類型訊息
        if ($this->event instanceof TextMessage) {
            $userId = $this->event->getUserId();
            $messageData = [
                'type' => 'user', // 標記為用戶發送的訊息
                'text' => $this->event->getText(),
                'timestamp' => $this->event->getTimestamp(),
                'sender_id' => $userId, // 用戶的 ID
            ];

            // 將訊息以 JSON 格式存入 Redis 的 List 結構
            Redis::rpush("user:{$userId}:messages", json_encode($messageData));

            // 將用戶 ID 加入活躍用戶集合 (Set),確保唯一性
            Redis::sadd('active_users', $userId);

            // 觸發新訊息事件,透過 WebSocket 推送給前端客服介面
            event(new NewLineMessage($userId, $messageData));
        }
        // TODO: 根據業務需求,在此處擴展處理圖片、貼圖、影片等其他 LINE 事件類型
    }
}

3. ChatController

提供客服介面 API,處理活躍用戶查詢、訊息歷史載入與回覆發送。

<?php
// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use LINE\LINEBot;
use LINE\LINEBot\HTTPClient\CurlHTTPClient;
use LINE\LINEBot\MessageBuilder\TextMessageBuilder;
use App\Events\NewLineMessage;
use Illuminate\Support\Facades\Log;

class ChatController extends Controller
{
    /**
     * 顯示客服介面主頁
     * @return \Illuminate\View\View
     */
    public function index()
    {
        return view('chat');
    }

    /**
     * 獲取活躍用戶列表
     * @return \Illuminate\Http\JsonResponse
     */
    public function getActiveUsers()
    {
        $userIds = Redis::smembers('active_users');
        return response()->json($userIds);
    }

    /**
     * 獲取指定用戶的訊息歷史
     * @param string $userId LINE 用戶 ID
     * @return \Illuminate\Http\JsonResponse
     */
    public function getMessages($userId)
    {
        $messages = Redis::lrange("user:{$userId}:messages", 0, -1);
        // 將 JSON 字串解碼為 PHP 陣列/物件
        $decodedMessages = array_map('json_decode', $messages);
        return response()->json($decodedMessages);
    }

    /**
     * 發送訊息至 LINE 用戶
     * 功能:客服人員透過介面發送訊息,調用 LINE API 推送訊息,並更新 Redis 與廣播事件
     * @param Request $request 請求物件,包含訊息內容
     * @param string $userId 目標 LINE 用戶 ID
     * @return \Illuminate\Http\JsonResponse
     */
    public function sendMessage(Request $request, $userId)
    {
        // 初始化 LINEBot 客戶端,使用 config 助手函數獲取憑證
        $httpClient = new CurlHTTPClient(config('services.line.channel_access_token'));
        $bot = new LINEBot($httpClient, ['channelSecret' => config('services.line.channel_secret')]);

        $text = $request->input('text');
        if (empty($text)) {
            return response()->json(['status' => '錯誤', 'message' => '訊息內容不能為空'], 400);
        }

        try {
            // 構建文字訊息並透過 LINE Messaging API 推送
            $response = $bot->pushMessage($userId, new TextMessageBuilder($text));

            if ($response->isSucceeded()) {
                // 如果 LINE API 發送成功,則將客服回覆儲存到 Redis
                $messageData = [
                    'type' => 'admin', // 標記為客服發送
                    'text' => $text,
                    'timestamp' => now()->timestamp, // 使用 Laravel 的 now() 輔助函數獲取當前時間戳
                    'sender_id' => 'admin' // 客服人員的標識
                ];
                Redis::rpush("user:{$userId}:messages", json_encode($messageData));
                
                // 觸發新訊息事件,透過 WebSocket 推送給所有監聽該頻道的客服介面
                event(new NewLineMessage($userId, $messageData));
                
                return response()->json(['status' => '成功']);
            } else {
                // 如果 LINE API 返回失敗,記錄詳細錯誤訊息
                $errorDetails = $response->getRawBody();
                Log::error("Failed to push message to LINE user {$userId}: " . $errorDetails);
                return response()->json(['status' => '錯誤', 'message' => '發送訊息到 LINE 失敗', 'details' => $errorDetails], 400);
            }
        } catch (\Exception $e) {
            // 捕獲其他運行時異常,例如網路問題等
            Log::error("Error sending message to LINE user {$userId}: " . $e->getMessage());
            return response()->json(['status' => '錯誤', 'message' => '伺服器內部錯誤', 'details' => $e->getMessage()], 500);
        }
    }
}

4. NewLineMessage Event

實現 WebSocket 廣播,將新訊息推送至特定用戶頻道。

<?php
// app/Events/NewLineMessage.php
namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;

class NewLineMessage implements ShouldBroadcast
{
    use InteractsWithSockets, SerializesModels;

    public $userId;
    public $message;

    /**
     * 建構子:初始化事件數據
     * @param string $userId 目標用戶 ID
     * @param array $message 訊息數據陣列 (包含 type, text, timestamp, sender_id)
     */
    public function __construct($userId, $message)
    {
        $this->userId = $userId;
        $this->message = $message;
    }

    /**
     * 定義廣播頻道
     * 功能:指定此事件將被廣播到哪個頻道,確保前端能監聽到
     * @return \Illuminate\Broadcasting\Channel
     */
    public function broadcastOn()
    {
        // 此處使用公共頻道 (Channel) 'chat.{userId}'。
        // 如果需要對頻道存取進行更嚴格的驗證(例如,只有特定權限的客服才能監聽),
        // 則應使用 PrivateChannel 並配置 channels.php 中的認證路由。
        return new Channel("chat.{$this->userId}");
    }
}

5. 客服介面 (resources/views/chat.blade.php)

使用 Vue.js 與 Laravel Echo 構建響應式即時聊天介面。

<!DOCTYPE html>
<html lang="zh-Hant">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}"> {{-- CSRF Token for AJAX requests --}}
    <title>LINE 客服系統</title>
    {{-- 引入 Vue.js、Laravel Echo 和 Pusher.js 函式庫 --}}
    <script src="[https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js](https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js)"></script>
    <script src="[https://cdn.jsdelivr.net/npm/laravel-echo@1.11.0/dist/echo.min.js](https://cdn.jsdelivr.net/npm/laravel-echo@1.11.0/dist/echo.min.js)"></script>
    <script src="[https://cdn.jsdelivr.net/npm/pusher-js@7.0.3/dist/pusher.min.js](https://cdn.jsdelivr.net/npm/pusher-js@7.0.3/dist/pusher.min.js)"></script>
    <style>
        /* 基本樣式與排版 */
        body { font-family: 'Inter', sans-serif; margin: 0; padding: 0; background-color: #f4f7f6; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
        .chat-container { display: flex; width: 90%; max-width: 1000px; margin: 20px auto; background-color: #fff; box-shadow: 0 5px 15px rgba(0,0,0,0.1); border-radius: 15px; overflow: hidden; min-height: 70vh; }
        .user-panel { flex: 1; border-right: 1px solid #eee; padding: 20px; background-color: #f8f8f8; overflow-y: auto; border-top-left-radius: 15px; border-bottom-left-radius: 15px; }
        .chat-panel { flex: 3; padding: 20px; display: flex; flex-direction: column; }
        h2 { color: #333; border-bottom: 1px solid #eee; padding-bottom: 10px; margin: 0 0 20px; }
        .user-list { list-style: none; padding: 0; margin: 0; }

        /* 用戶列表項目樣式 */
        .user-item { padding: 12px 15px; cursor: pointer; border-bottom: 1px solid #eee; transition: background-color 0.2s; border-radius: 8px; margin-bottom: 5px; }
        .user-item:hover { background-color: #eef; }
        .user-item.active { background-color: #cfe2f3; font-weight: bold; }

        /* 訊息列表與泡泡樣式 */
        .message-list { list-style: none; padding: 0; margin-bottom: 20px; overflow-y: auto; flex-grow: 1; padding-right: 10px; }
        .message { padding: 10px 15px; margin-bottom: 10px; border-radius: 18px; max-width: 75%; word-wrap: break-word; line-height: 1.4; font-size: 0.95em; box-shadow: 0 1px 2px rgba(0,0,0,0.05); }
        .user-message { background-color: #dcf8c6; text-align: left; margin-right: auto; }
        .admin-message { background-color: #e0f7fa; text-align: right; margin-left: auto; }
        .message span { display: block; font-size: 0.8em; color: #666; margin-bottom: 3px; } /* 發送者與時間 */

        /* 訊息輸入區塊樣式 */
        .message-input-container { display: flex; align-items: center; margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px; }
        .message-input { flex-grow: 1; padding: 12px 15px; border: 1px solid #ccc; border-radius: 25px; margin-right: 10px; font-size: 1em; outline: none; transition: border-color 0.2s; }
        .message-input:focus { border-color: #007bff; }
        .send-button { padding: 12px 25px; background-color: #007bff; color: white; border: none; border-radius: 25px; cursor: pointer; font-size: 1em; transition: background-color 0.2s, box-shadow 0.2s; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }
        .send-button:hover { background-color: #0056b3; box-shadow: 0 3px 8px rgba(0,0,0,0.3); }

        /* RWD 響應式調整 */
        @media (max-width: 768px) {
            .chat-container { flex-direction: column; width: 95%; min-height: 90vh; }
            .user-panel { border-right: none; border-bottom: 1px solid #eee; border-bottom-left-radius: 0; border-top-right-radius: 15px; }
            .chat-panel { padding-top: 10px; }
        }
    </style>
</head>
<body>
    <div id="app" class="chat-container">
        {{-- 左側活躍用戶列表 --}}
        <div class="user-panel">
            <h2>活躍用戶</h2>
            <ul class="user-list">
                <li v-for="user in activeUsers" :key="user" @click="selectUser(user)" :class="{ 'active': user === selectedUser }" class="user-item">
                    用戶 ID: {{ user }}
                </li>
                <li v-if="activeUsers.length === 0" class="user-item" style="cursor: default; text-align: center; color: #888;">
                    目前無活躍用戶
                </li>
            </ul>
        </div>
        {{-- 右側聊天介面 --}}
        <div v-if="selectedUser" class="chat-panel">
            <h2>與 {{ selectedUser }} 的對話</h2>
            <ul class="message-list">
                <li v-for="(message, index) in messages" :key="message.timestamp + '_' + index" :class="{ 'user-message': message.type !== 'admin', 'admin-message': message.type === 'admin' }" class="message">
                    <span>{{ message.type !== 'admin' ? '用戶' : '您' }} ({{ new Date(message.timestamp * 1000).toLocaleTimeString('zh-TW') }}):</span>
                    {{ message.text }}
                </li>
            </ul>
            {{-- 訊息輸入與發送區塊 --}}
            <div class="message-input-container">
                <input v-model="newMessage" @keyup.enter="sendMessage" placeholder="輸入訊息..." class="message-input">
                <button @click="sendMessage" class="send-button">發送</button>
            </div>
        </div>
        {{-- 未選中用戶時的提示 --}}
        <div v-else class="chat-panel" style="display: flex; justify-content: center; align-items: center; color: #888;">
            請選擇一位用戶開始對話
        </div>
    </div>

    <script>
        // 確保 Pusher.js 全局可用,Laravel Echo 依賴它
        window.Pusher = Pusher;

        // 初始化 Laravel Echo 進行 WebSocket 連線配置
        window.Echo = new Echo({
            broadcaster: 'pusher',
            key: '{{ env('PUSHER_APP_KEY') }}', // 從 Laravel 環境變數獲取 Pusher Key
            cluster: '{{ env('PUSHER_APP_CLUSTER', 'mt1') }}', // 從 Laravel 環境變數獲取 Pusher Cluster
            wsHost: window.location.hostname, // WebSocket 伺服器主機,通常與網站同主機
            wsPort: 6001, // 預設的 Laravel Websockets 連接埠
            wssPort: 6001, // 啟用 SSL/TLS 時的連接埠
            forceTLS: false, // 依據您的開發或生產環境設定,生產環境建議設為 true
            disableStats: true, // 禁用 Pusher 統計數據
            enabledTransports: ['ws', 'wss'] // 明確啟用 WebSocket 傳輸方式
        });

        // Vue.js 前端邏輯
        new Vue({
            el: '#app', // Vue 應用程式掛載點
            data: {
                activeUsers: [],     // 儲存活躍用戶 ID 列表
                selectedUser: null,  // 當前選中的用戶 ID
                messages: [],        // 儲存當前對話的聊天訊息
                newMessage: '',      // 用於 v-model 綁定訊息輸入框內容
                currentChannel: null // 儲存當前監聽的 WebSocket 頻道實例,用於管理頻道的訂閱與取消訂閱
            },
            mounted() {
                // 頁面載入時立即獲取活躍用戶列表
                this.fetchActiveUsers();
                // 設定定時器,每 5 秒更新一次活躍用戶列表,確保最新狀態
                setInterval(this.fetchActiveUsers, 5000);
            },
            methods: {
                /**
                 * 獲取活躍用戶列表
                 * 向後端 API 請求當前所有活躍用戶的 ID
                 */
                fetchActiveUsers() {
                    fetch('/api/active-users')
                        .then(response => {
                            // 檢查網路響應是否成功 (HTTP 狀態碼 2xx)
                            if (!response.ok) {
                                throw new Error('Network response was not ok ' + response.statusText);
                            }
                            return response.json();
                        })
                        .then(data => {
                            this.activeUsers = data; // 更新 Vue 實例中的活躍用戶數據

                            // 如果當前沒有選中用戶,或選中的用戶已不活躍,則自動選擇第一個活躍用戶
                            if (this.activeUsers.length > 0 && (!this.selectedUser || !this.activeUsers.includes(this.selectedUser))) {
                                this.selectUser(this.activeUsers[0]);
                            } else if (this.activeUsers.length === 0) {
                                // 如果沒有任何活躍用戶,清空選中狀態和訊息
                                this.selectedUser = null;
                                this.messages = [];
                                // 如果有正在監聽的頻道,則離開該頻道
                                if (this.currentChannel) {
                                    window.Echo.leave(this.currentChannel.name);
                                    this.currentChannel = null;
                                }
                            }
                        })
                        .catch(error => {
                            console.error('Error fetching active users:', error);
                        });
                },

                /**
                 * 選擇用戶並載入其聊天記錄
                 * @param {string} userId - 要選擇的用戶 ID
                 */
                selectUser(userId) {
                    // 如果選擇的是當前已選中的用戶,則不做任何操作
                    if (this.selectedUser === userId) return;

                    // 離開前一個用戶的 WebSocket 頻道,避免重複監聽和資源浪費
                    if (this.currentChannel) {
                        window.Echo.leave(this.currentChannel.name);
                        console.log(`Left channel: ${this.currentChannel.name}`);
                    }

                    this.selectedUser = userId; // 更新當前選中的用戶
                    this.fetchMessages(userId); // 載入該用戶的歷史訊息
                    
                    // 監聽該用戶的 WebSocket 頻道,接收即時新訊息
                    // NewLineMessage 事件在後端定義為 Channel,所以前端也使用 .channel()
                    this.currentChannel = window.Echo.channel(`chat.${userId}`);
                    this.currentChannel.listen('NewLineMessage', (e) => {
                        console.log('New message received via WebSocket:', e.message);
                        this.messages.push(e.message); // 將新訊息添加到訊息列表
                        // 確保訊息列表自動滾動到底部,顯示最新訊息
                        this.$nextTick(() => {
                            const messageListElement = document.querySelector('.message-list');
                            if (messageListElement) {
                                messageListElement.scrollTop = messageListElement.scrollHeight;
                            }
                        });
                    }).error((error) => {
                        // 處理 WebSocket 頻道監聽錯誤
                        console.error('WebSocket Channel Error:', error);
                    });
                    console.log(`Now listening on channel: chat.${userId}`);
                },

                /**
                 * 獲取指定用戶的訊息歷史
                 * @param {string} userId - 要獲取訊息的用戶 ID
                 */
                fetchMessages(userId) {
                    fetch(`/api/messages/${userId}`)
                        .then(response => {
                            if (!response.ok) {
                                throw new Error('Network response was not ok ' + response.statusText);
                            }
                            return response.json();
                        })
                        .then(data => {
                            this.messages = data; // 更新訊息列表數據
                            // 載入所有歷史訊息後,滾動到最底部
                            this.$nextTick(() => {
                                const messageListElement = document.querySelector('.message-list');
                                if (messageListElement) {
                                    messageListElement.scrollTop = messageListElement.scrollHeight;
                                }
                            });
                        })
                        .catch(error => {
                            console.error('Error fetching messages:', error);
                            this.messages = []; // 載入失敗則清空訊息列表
                        });
                },

                /**
                 * 發送訊息
                 * 客服人員輸入訊息後點擊發送或按 Enter 鍵時觸發
                 */
                sendMessage() {
                    // 檢查訊息內容是否為空或未選擇用戶
                    if (!this.newMessage.trim() || !this.selectedUser) return;

                    const messageToSend = this.newMessage.trim(); // 獲取要發送的訊息內容並去除空白
                    this.newMessage = ''; // 發送前立即清空輸入框,提供即時反饋

                    // 樂觀更新:立即在前端顯示客服自己發送的訊息
                    // 這樣用戶體驗會更流暢,無需等待後端響應
                    this.messages.push({
                        type: 'admin',
                        text: messageToSend,
                        timestamp: Math.floor(Date.now() / 1000), // 使用當前時間戳
                        sender_id: 'admin' // 標記為客服發送
                    });
                    // 確保樂觀更新後訊息列表滾動到最底部
                    this.$nextTick(() => {
                        const messageListElement = document.querySelector('.message-list');
                        if (messageListElement) {
                            messageListElement.scrollTop = messageListElement.scrollHeight;
                        }
                    });

                    // 向後端 API 發送 POST 請求
                    fetch(`/api/send-message/${this.selectedUser}`, {
                        method: 'POST',
                        headers: {
                            'Content-Type': 'application/json',
                            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content') // 引入 CSRF Token 確保安全性
                        },
                        body: JSON.stringify({ text: messageToSend }) // 將訊息內容轉為 JSON 格式
                    })
                    .then(response => response.json())
                    .then(data => {
                        // 根據後端響應判斷訊息是否發送成功
                        if (data.status !== '成功') {
                            console.error('發送訊息失敗:', data.message || data.details);
                            alert('發送訊息失敗!' + (data.message || ''));
                            // 如果發送失敗,考慮將之前樂觀更新的訊息移除或標記錯誤
                            this.messages.pop(); 
                        } else {
                            console.log('訊息發送成功');
                        }
                    })
                    .catch(error => {
                        // 處理網路請求錯誤
                        console.error('發送訊息時發生錯誤!', error);
                        alert('發送訊息時發生錯誤!');
                        // 如果發生錯誤,也移除樂觀更新的訊息
                        this.messages.pop(); 
                    });
                }
            }
        });
    </script>
</body>
</html>

6. 環境變數配置 (.env 範例)

確保敏感資訊與程式碼分離,遵循安全最佳實踐。

# Redis 配置
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0 # 選擇 Redis 資料庫索引

# LINE Messaging API 配置
LINE_CHANNEL_ACCESS_TOKEN=your_line_channel_access_token # 從 LINE Developers Console 獲取
LINE_CHANNEL_SECRET=your_line_channel_secret       # 從 LINE Developers Console 獲取

# Laravel 廣播驅動 (使用 Redis 作為底層)
BROADCAST_DRIVER=redis

# Pusher/Laravel Websockets 配置
# 這些是 Laravel Websockets 模擬 Pusher API 所需的配置。
# 如果您使用 Pusher 官方服務,請替換為 Pusher 提供的實際金鑰和 ID。
PUSHER_APP_ID=your_pusher_app_id
PUSHER_APP_KEY=your_pusher_app_key
PUSHER_APP_SECRET=your_pusher_app_secret
PUSHER_APP_CLUSTER=mt1 # 根據您的地理位置選擇合適的 cluster,例如 'ap1' 或 'mt1'

部署與運維實務

為確保系統在生產環境的穩定運行,我在部署與運維層面做了以下考量:

  1. 佇列管理與監控: 我會使用 php artisan queue:work --queue=line_webhook,default --tries=3 來啟動佇列工作者,--queue 參數指定監聽的佇列,--tries 則定義了任務失敗時的重試次數。在生產環境中,我會搭配 SupervisorSystemd 等進程管理工具,實現佇列工作者的守護進程,確保其始終運行、自動重啟,從而保障任務處理的高可用性。

  2. WebSocket 伺服器部署: php artisan websockets:serve 作為獨立的 WebSocket 服務,需要確保部署在穩定的環境中。我會透過 NginxApache 配置反向代理,正確處理 WebSocket 的 UpgradeConnection 頭,將流量導向 WebSocket 伺服器。同時,防火牆配置需允許 6001 端口(或其他指定端口)的外部連線。

  3. 環境安全與配置管理: 所有敏感資訊(如 LINE Channel Secret、Access Token、Pusher/WebSocket 密鑰)都嚴格儲存於 .env 文件中,與程式碼分離,絕不直接硬編碼。在 CI/CD 流程中,這些環境變數會被安全地注入到部署環境,降低潛在的資料洩露風險。

  4. 監控與日誌策略: 為了及時發現並解決問題,我會整合 Laravel 內建的日誌系統與外部監控工具(如 Sentry 或 ELK Stack)。這將使我能夠實時追蹤 Webhook 請求的失敗、佇列任務的延遲、LINE API 調用異常等關鍵指標,從而實現快速的問題定位與響應。

  5. 高可用與擴展性設計:

    • Redis: 支援叢集模式(Redis Cluster),可通過分片實現數據分佈與水平擴展。

    • 佇列: 可輕鬆增加多個佇列工作者實例,甚至可配置不同優先級的佇列來處理不同重要級別的任務。

    • WebSocket 伺服器: Laravel Websockets 也支援多節點部署,可搭配負載均衡器,確保高併發連線下的穩定性與可擴展性。

      這些設計確保系統能夠隨業務流量的增長而彈性擴展,保持其性能與穩定性。

總結:以技術驅動業務價值

這套 LINE 客服系統的設計與實作,充分展現了我作為資深 PHP 工程師在高併發系統架構、異步處理與即時通訊領域的能力。透過對 Laravel 生態系的深入理解和靈活運用,結合 Redis 的高效能與 WebSocket 的即時性,我成功打造了一個兼具性能、穩定性與用戶體驗的解決方案。


具體是如何處理同時大流量的問題:

這套系統的設計,就是為了在高流量情境下,確保系統的穩定性、低延遲與可擴展性。關鍵在於分而治之,異步處理,並確保各環節都能水平擴展

核心應對策略概述

在面對同時大流量時,我主要依賴以下五大策略協同運作:

  1. 異步處理與佇列削峰 (Asynchronous Processing & Queue Throttling)
  2. Redis 高效存取 (High-Performance Redis Access)
  3. WebSocket 即時通訊與負載分散 (Real-time WebSocket & Load Distribution)
  4. 全面水平擴展與負載均衡 (Holistic Horizontal Scaling & Load Balancing)
  5. 嚴謹監控與錯誤處理 (Robust Monitoring & Error Handling)

具體實踐與高流量場景應對

以下我將針對各個組件,說明其在高流量下的具體應對機制與潛在瓶頸及解決方案:

1. Webhook 處理層 (Laravel 應用程式前端 - LineWebhookController)

  • 流量衝擊: 大量 LINE 用戶同時發送訊息,會導致 Webhook POST /line/webhook 請求瞬間激增。
  • 應對機制:
    • 快速響應: LineWebhookController 的設計極致輕量化。它只負責驗證簽名解析事件,隨後立即將完整的事件數據分派到佇列ProcessLineWebhookEvent::dispatch($event)->onQueue('line_webhook')),並迅速回覆 LINE Platform 200 OK。這種異步分派確保了 Webhook 響應時間在毫秒級 (<100ms),從根本上避免了 LINE 因超時而重複發送事件。
  • 高流量應對:
    • 水平擴展 Web 伺服器: 我會部署多個 Laravel 應用節點,並在前端配置 Nginx 反向代理AWS Application Load Balancer (ALB) 進行負載均衡。請求會被均勻地分發到各個 Web 節點,每個節點只執行輕量級的 Webhook 接收邏輯。我會參考在 AWS ECS 部署高併發 API 的經驗,利用 ECS 與 Auto Scaling Group 根據 CPU 或請求量自動擴展 Web 伺服器實例。

2. 異步任務處理層 (Laravel Queues - ProcessLineWebhookEvent Job & Queue Workers)

  • 流量衝擊: Webhook 層將所有事件推入佇列後,佇列的任務量將會激增。如果處理速度跟不上,佇列就會積壓。
  • 應對機制:
    • 流量緩衝: 佇列作為天然的流量緩衝區。即使在 Webhook 請求量瞬間激增時,訊息也能有序排隊,而非直接丟失或導致系統崩潰。
    • 異步執行: 耗時的業務邏輯(如訊息儲存到 Redis、觸發 WebSocket 廣播)都由 Queue Workers 在後台執行,不阻塞 Web 請求。
  • 高流量應對:
    • 水平擴展佇列工作者: 這是處理大流量最核心且直接的擴容點。我會根據佇列的積壓深度和單個任務的平均處理時間,動態增加運行 php artisan queue:work 的伺服器實例或容器數量。這可透過 Supervisor 進行進程管理,並結合雲服務平台(如 AWS Auto Scaling Group)實現基於佇列指標(例如佇列長度)的自動擴展。
    • 任務優化: 持續分析 ProcessLineWebhookEvent Job 的執行效率,確保沒有冗餘或低效的操作,甚至考慮批次處理某些特定事件以提升整體吞吐量。
    • 佇列分級: 對於不同優先級的訊息(例如文字訊息可能比系統日誌需要更快的響應),可以設置多個佇列和不同數量的 Worker。

3. 數據存取層 (Redis)

  • 流量衝擊: 大量訊息的寫入(RPUSH)和活躍用戶的更新(SADD),以及客服介面頻繁的讀取(SMEMBERSLRANGE),會對 Redis 產生巨大的讀寫壓力。
  • 應對機制:
    • 記憶體級性能: Redis 作為記憶體資料庫,其讀寫操作本身就極快(毫秒級延遲),在高併發下仍能保持高效。
    • 數據結構優化: 選擇 List 儲存聊天記錄、Set 儲存活躍用戶,這些數據結構非常適合各自的應用場景,提供了高效的查詢和操作。
  • 高流量應對:
    • 垂直擴展: 初期可透過升級 Redis 伺服器(增加記憶體和 CPU)來提升性能。
    • 水平擴展 (Redis Cluster): 當數據量和 QPS 達到單機極限時,我會部署 Redis Cluster。它將數據分片到多個節點,自動實現讀寫負載分散,並提供高可用性。
    • 記憶體管理與數據歸檔: 我會設定合理的數據淘汰策略(maxmemory-policy),並將超過一定時間(例如 7 天)的聊天記錄從 Redis 歸檔到持久化儲存(如 MySQL 或 MongoDB),確保 Redis 僅用於「熱數據」,控制記憶體增長。這與我之前處理高併發交易場景中,利用 Redis 原子操作(如 INCR)來計數和控制資源的經驗類似。

4. 即時通訊層 (WebSocket 伺服器 - Laravel Echo Server)

  • 流量衝擊: 大量用戶訊息的即時推送,以及多個客服同時監聽大量用戶頻道,會導致 WebSocket 伺服器並發連接數和推送頻率激增。
  • 應對機制:
    • 持久連線: WebSocket 相較於 HTTP 輪詢,減少了大量的連接建立和斷開開銷,極大地降低了伺服器的負擔。
    • 廣播事件 (NewLineMessage Event): 訊息透過廣播事件發送,底層由 Redis Pub/Sub 驅動,確保高效的訊息分發。
  • 高流量應對:
    • 水平擴展 WebSocket 伺服器: 我會部署多個 Laravel Websockets 伺服器實例,並搭配前端的負載均衡器來分散客戶端連線。多個 WebSocket 實例可以透過 Redis Pub/Sub 同步訊息,實現高效廣播。
    • 前端頻道管理優化: 客服介面在選擇用戶時,會動態地訂閱和取消訂閱 WebSocket 頻道(window.Echo.leave()),確保客服人員只監聽當前活躍對話的頻道,避免不必要的資源消耗。
    • 監控: 我會持續監控 Laravel Websockets 的內建統計功能,追蹤並發連線數和推送頻率,確保資源充足。

5. 外部 API 調用 (LINE Messaging API)

  • 流量衝擊: 客服人員回覆訊息的數量激增,可能觸發 LINE Messaging API 的速率限制。
  • 應對機制:
    • 獨立佇列: 雖然在 ChatController 中直接調用,但可以將發送回覆的邏輯也放入一個獨立的佇列 Job(例如 SendLineMessageJob),與 Webhook 處理佇列分開。
  • 高流量應對:
    • 引入 API 請求佇列與流量控制:ChatController 或專門的 SendLineMessageJob 中,我會實施嚴格的速率限制(Rate Limiting,例如 Laravel 的 Throttling 或使用第三方 Rate Limiter)和重試機制(Exponential Backoff)。這確保我們發送給 LINE API 的請求符合其速率限制要求,避免被節流或封禁,這是我處理第三方 API 限流的標準策略。

6. 整體監控與自動化恢復

  • 流量衝擊: 高流量下,任何環節的異常都可能被放大,需要快速發現並處理。
  • 應對機制:
    • 全面日誌記錄: 在所有關鍵控制器和 Job 中使用 Laravel 的 Log facade 記錄操作和異常。
    • 實時監控: 部署 Prometheus + Grafana,實時監控各服務(Web 伺服器、佇列、Redis、WebSocket)的 CPU、記憶體、網路 I/O、佇列深度、Redis QPS、WebSocket 連接數、API 延遲和錯誤率等關鍵指標。
    • 自動化警報: 設定基於閾值的警報(例如佇列積壓過高、Redis 記憶體接近上限),透過郵件或即時通訊工具通知運維團隊。
    • 自動恢復: 搭配 Supervisor 自動重啟失敗的佇列工作者,並利用雲服務提供商的自動擴展策略,實現節點的自動增減。

總結:應對大流量的韌性架構

總而言之,我處理同時大流量的核心思路是:將核心業務邏輯異步化以解耦,利用高速緩存提升響應速度,採用持久連線減少資源消耗,並確保所有組件都能水平擴展。透過精細化的監控和自動化運維,我們能夠在流量激增時快速識別瓶頸並彈性應對,保障 LINE 客服系統在高負載下的穩定運行和優質的客戶體驗。

沒有留言:

張貼留言