Phase 3: 私訊與持久化

Step 07 — 08 | 從公開聊天室進化成 1 對 1 私訊

架構變化

之前(公開聊天室): User A 說話 → Hub.Broadcast → 所有人收到 現在(私訊): User A 對 B 說話 → Hub.SendToUser(B) → 只有 B 收到 → Hub.SendToUser(A) → A 自己也收到(顯示在畫面上)

WebSocket 訊息協定

前端 → 後端(JSON)

// 前端送出
ws.send(JSON.stringify({
    type: "pm",
    to: 1,        // 對方的 userID
    text: "你好"
}));

後端 → 前端(字串)

// 在線名單
[System] Users: 1:Alice,2:Bob,3:Carol

// 私訊
[PM:Alice:14:30:05] 你好

為什麼不全用 JSON?在線名單和系統訊息用字串格式更簡單。私訊的發送端用 JSON 是因為需要帶型別(userID 是 uint),字串解析會很醜。

O(1) 用戶查找

為什麼需要兩個 map

type Hub struct {
    Clients         map[*Client]bool     // 遍歷所有人(廣播用)
    ClientsByUserID map[uint]*Client     // O(1) 查找(私訊用)
}
操作用哪個 map複雜度
廣播在線名單Clients(遍歷)O(n)
私訊指定用戶ClientsByUserID(查找)O(1)
Register兩個都加O(1)
Unregister兩個都刪O(1)

SendToUser

func (h *Hub) SendToUser(userID uint, message []byte) {
    h.mu.Lock()
    defer h.mu.Unlock()

    if client, ok := h.ClientsByUserID[userID]; ok {
        select {
        case client.Send <- message:
        default:  // client 卡住了,跳過
        }
    }
}

聊天記錄持久化

Message Model

type Message struct {
    ID         uint
    SenderID   uint       // 誰送的
    ReceiverID uint       // 送給誰
    Content    string     // 訊息內容
    CreatedAt  time.Time  // 自動填入
}

查詢兩人對話

DB.Where(
    "(sender_id = ? AND receiver_id = ?) OR (sender_id = ? AND receiver_id = ?)",
    userID, peerID, peerID, userID,
).Order("created_at ASC").Limit(100).Find(&messages)

A 發給 B 的 + B 發給 A 的,按時間排序,最多 100 筆。

資料流

發訊息: 前端 ws.send(JSON) → readPump 解析 → repository.SaveMessage() 存 DB → hub.SendToUser(對方) 即時推送 → hub.SendToUser(自己) 自己也看到 開聊天: 前端 fetch /api/messages?peer_id=1 → handler 從 JWT 取 userID → repository.GetMessages(userID, peerID, 100) → 回傳 JSON → 前端渲染歷史訊息

歷史訊息只在第一次開聊天時拉。之後的新訊息由 WebSocket 即時推送,不重複查 DB。

JWT claims 的 float64 問題

// JWT MapClaims 把所有數字解析成 float64(JSON 規格)
UserID: uint(c.GetFloat64("user_id"))

JSON 沒有整數型別,所有數字都是浮點數。所以從 JWT claims 取出後要轉型。