免费

让 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 和会话头部尺寸塌了

.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——一晚上的事。但里面真正稀缺的不是写代码的时间,是把范围、权限、骚扰防护这三件事想清楚的时间。代码写完只是开始,自己用一遍、把毛刺磨掉,才是真的上线。