免費

讓 Claude 把 1:1 私訊功能搭到能上線

讓 Claude 一晚上搭出 1:1 私訊:範圍控制、反騷擾三層、Turbo 即時——以及 polish 的三個細節。


Pickful 今天上線了私訊。一對一聊天、文字+圖片、即時送達、2 分鐘撤回、按積分分檔限速、隱私三檔可調、封鎖生效——38 個檔案、+1376 行、41 條 spec,一個 PR 合掉。

這篇文章不寫「Claude 真厲害」,寫的是:當你要 Claude 給一個跑了一年多的社群產品加一個真的會被人騷擾的功能時,哪些決策必須當面拍板、哪些細節 Claude 第一遍會漏掉、第二輪我自己又補了什麼。

把私訊想清楚再開寫

私訊跟留言不一樣。留言是廣場,丟出去人人能看;私訊是密室,進去就只有兩個人。這意味著任何一個資料建模、權限策略、推送通道的小漏洞,都直接等於「陌生人能往你臉上糊廣告」。所以我沒有直接說「實作一個聊天」,先跟 Claude 把範圍鎖死:

  • 只做 1:1——群聊涉及成員管理、@mention、誰看到誰,複雜度直接 ×3
  • 不做已讀回條——隱私敏感,先不做,留出後續可選開關的位置
  • 不做 emoji reaction——1:1 場景裡互傳貼圖就夠了,reaction 是低優先

Claude 完全理解「先把刀刃磨亮,再決定砍多大的樹」。這點我後面會反覆用到——AI 寫程式很快,但產品邊界要靠人定。

一對使用者 = 一條會話:怎麼避免出現 (A,B) 和 (B,A) 兩條

私訊資料建模最常見的坑:A 給 B 發訊息建一條 (A→B) 的 conversation,B 回來又建一條 (B→A),於是兩條並行不通氣。

Claude 提的方案我直接接受:

def self.between!(a, b)
  raise ArgumentError, "cannot create conversation with self" if a.id == b.id
  one_id, two_id = [a.id, b.id].sort
  find_or_create_by!(user_one_id: one_id, user_two_id: two_id)
end

[a.id, b.id].sort + (user_one_id, user_two_id) 唯一索引——無論誰先開口,最終落到同一行。簡單直接,不需要做遷移合併、不需要在應用層關聯兩筆紀錄。

後續的 peer(viewer) 也跟著乾淨:

def peer(viewer)
  viewer.id == user_one_id ? user_two : user_one
end

「對方是誰」永遠是一行三元表達式。這種約束放在資料庫層的寫法,比應用層判斷穩得多。

狀態分到兩邊各存一份

會話有兩類狀態本質上是「單邊」的:

  • 是否已讀:A 讀了不代表 B 讀了
  • 是否隱藏會話:A 把對話刪了不能影響 B 那邊

最早的草案我讓 Claude 寫了一個 conversation_states 表,per-user 一行。Claude 寫出來之後我讓它停一下——這表上每條會話最多 2 行,永遠 2 行,多一張表多一層 join 不划算。改成直接在 conversations 上加四欄:

t.datetime :user_one_last_read_at
t.datetime :user_two_last_read_at
t.datetime :user_one_hidden_at
t.datetime :user_two_hidden_at

讀寫的時候根據 viewer 是 one 還是 two 拼欄位名:

def column_name(viewer, suffix)
  viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end

程式裡多了一點 viewer dispatch,省了一張 join 表和一組索引。「行數永遠固定」的關係不該建表——這是資料庫設計裡的判斷題,Claude 給方案,但是否合併要人來定。

反騷擾從第一天就做厚

我在 prompt 裡寫得很死:「私訊能上線的前提是預設抗騷擾,開關全部預設收緊」。Claude 給出來的三層防禦我一字沒改:

機制 實作位置
使用者主動封鎖 blocked_users.exists?(id: other.id) 命中直接擋 can_be_dmed_by?
隱私三檔 enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } User concern
按積分分檔限速 <50pts→10/h,<500pts→60/h,否則 300/h dm_hourly_limit

第三層是我後加要求的。社群產品上做私訊,新帳號刷屏是必經的災難。Claude 第一版限速給的是「所有人 60/h 寫死」,我讓它按積分分檔——讓零成本的新帳號幾乎沒有聲量,讓真正的活躍使用者夠用。這種「不要在程式層平均公平、要在使用者分層裡設計公平」的細節,是產品判斷不是技術判斷。

權限收口在 Pundit policy 裡,一條都不能繞:

def create?
  return false unless user
  return false unless record.conversation&.participant?(user)
  recipient = record.conversation.peer(user)
  return false unless recipient
  return false unless recipient.can_be_dmed_by?(user)
  return false if user.dm_rate_limited?
  true
end

每個 return false 都對應一種騷擾場景。程式長得跟清單一樣——這正是 policy 該有的樣子。

2 分鐘撤回,軟刪,保留稽核痕跡

撤回功能是必須做的(發錯字、發錯圖、發錯人),但實作細節很容易踩坑:

RETRACT_WINDOW = 2.minutes

def retractable_by?(user)
  !deleted? && sender_id == user&.id && created_at >= RETRACT_WINDOW.ago
end

def retract!
  update!(deleted_at: Time.current, content: nil)
  image.purge_later if image.attached?
end

要點:

  • 軟刪——deleted_at 打時間戳,原文清空,列不刪
  • 圖片真刪——purge_later 把附件丟去後台清理,節省儲存
  • 2 分鐘硬視窗——避免人翻舊帳撤回 6 個月前的話
  • 列留住——以備合規和稽核;同時 Turbo broadcast 把那條訊息替換成「已撤回」的占位

「內容清空但紀錄留下」是私訊場景的標準做法。Claude 第一遍就把這邏輯寫對了,但 image.purge_later 是我讓它加的——它原來寫的是 image.purge,那會阻塞回應。

Turbo 推送按「人」不按「會話」

即時這塊我讓 Claude 用 Hotwire/Turbo,因為整個 app 就是這套。重點是 broadcast channel 怎麼切:

def broadcast_to_thread
  [conversation.user_one, conversation.user_two].each do |viewer|
    broadcast_append_to(
      "conversation_#{conversation_id}_user_#{viewer.id}",
      target: "conversation_#{conversation_id}_messages",
      partial: "direct_messages/direct_message",
      locals: { message: self, viewer: viewer }
    )
  end
end

channel 名帶 viewer.id——同一條訊息廣播兩次,給兩個不同的 stream,每邊渲染時 viewer 不同。

為什麼這麼做?因為訊息的 partial 裡有「這條是我發的還是對方發的」「我能不能撤回」這兩個分支,渲染結果天然就不一樣。如果只 broadcast 一份再讓前端判斷,要麼把 sender_id 暴露給前端、要麼寫一堆 CSS 條件——都不如伺服端按 viewer 分兩次渲染來得清楚。

inbox 紅點也是同一思路:

Turbo::StreamsChannel.broadcast_replace_to(
  "user_#{recipient_id}_inbox",
  target: "dm_inbox_badge",
  html: ApplicationController.render(
    partial: "shared/dm_inbox_badge",
    locals: { unread: recipient.total_unread_dm_count }
  )
)

每個使用者一個獨立 stream,未讀數自己更新自己的。

Claude 寫完之後,我自己又補了什麼

PR 合進去之後我盯著真實互動用了幾個小時,發現幾個 Claude 沒想到的問題,專門發了一個 polish 提交:

1. 空會話不能讓兩邊都看到

我點了別人的「發私訊」按鈕,會話被建出來了,但我還沒發出任何訊息——這時候對方點開 inbox 就能看到一條空會話。社交體驗上很奇怪:「你不發就別打擾我」。

加了一條 scope:

scope :visible_to, ->(user) {
  for_user(user)
    .where.not(last_message_at: nil)
    .where(...hidden_at IS NULL...)
}

last_message_at IS NULL 的會話兩邊都看不到,等第一條訊息進來後 bump_last_message! 一打時間戳就出現。Claude 沒主動想到這點——它只關心「能不能用」,沒關心「如果使用者改主意會怎樣」。

2. profile 上的「發私訊」按鈕跟 follow 按鈕疊在一起太擠

Claude 給的是上下堆疊,加上「Chat-bubble-left-right」的 heroicon tooltip 名字直接漏出來。我改成跟 follow 並排、icon-only,顯式給 title 覆蓋那個 tooltip。

3. 頭像在 inbox 和會話 header 尺寸塌了

.avatar-wrapper 有個 width lock 跟 Tailwind 的 w-X h-X 打架。改成跟其它地方的 user_card 一樣直接 w-X h-X rounded-full object-cover

這三件事單獨看都很小,加起來就是「能用」和「真的好用」之間的距離。Claude 寫程式飛快,但這種「自己用一下才會發現」的瑕疵,它無法替你發現——它沒有眼睛、沒有手指、沒有同一個產品裡其它頁面的肌肉記憶。

寫完這個功能我學到的事

1. 範圍控制是人的活,不是 AI 的活

「不做群聊、不做已讀、不做 reaction」這三件事比 Claude 寫的所有程式都重要。一旦放開任何一項,工作量翻倍、上線日期推遲兩週。Claude 不會主動幫你砍範圍,因為它不知道你的發布節奏。

2. 把不變量塞進資料庫層

[a.id, b.id].sort + 唯一索引、distinct_participants validation、外鍵 cascade——這些都是 Claude 提的,但前提是我說了「不允許出現兩條鏡像會話」「不允許給自己發訊息」「使用者刪除時清乾淨」。約束寫得越死,後續 bug 越少。

3. 反騷擾要在 spec 裡

41 條 spec 裡有 60% 是 policy 測試:封鎖生效不生效、隱私設定生效不生效、限速生效不生效。這些 spec 不是測「功能跑不跑」,是測「濫用擋不擋」。每加一種騷擾場景就加一條 spec——這件事 Claude 完全可以自己列清單,但你得先告訴它「我們要防的是哪些場景」。

4. UX polish 必須自己上手

我把 PR 合進去之後用了兩小時真實互動,才發現那三個問題。如果只看 PR diff、只跑測試,永遠發現不了。


私訊這個功能從「我想做」到「上線可用」,主倉 PR 提交時間 21:53,polish 提交時間 23:36——一晚上的事。但裡面真正稀缺的不是寫程式的時間,是把範圍、權限、騷擾防護這三件事想清楚的時間。程式寫完只是開始,自己用一遍、把毛刺磨掉,才是真的上線。