让 Claude 一晚上搭出 1:1 私信:范围控制、反骚扰三层、Turbo 实时——以及 polish 的三个细节。
Pickful 今天上线了私信。一对一聊天、文字+图片、实时送达、2 分钟撤回、按积分分档限速、隐私三档可调、屏蔽生效——38 个文件、+1376 行、41 条 spec,一个 PR 合掉。
这篇文章不写「Claude 真厉害」,写的是:当你要 Claude 给一个跑了一年多的社交产品加一个真正会被人骚扰的功能时,哪些决策必须当面拍板、哪些细节 Claude 第一遍会漏掉、第二轮我自己又补了什么。
私信跟评论不一样。评论是广场,丢出去人人能看;私信是密室,进去就只有两个人。这意味着任何一个数据建模、权限策略、推送通道的小漏洞,都直接等于「陌生人能给你脸上糊广告」。所以我没有直接说「实现一个聊天」,先跟 Claude 把范围限死:
Claude 完全理解「先把刀刃磨亮,再决定砍多大的树」。这点我后面会反复用到——AI 写代码很快,但产品边界要靠人定。
私聊数据建模最常见的坑: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
「对方是谁」永远是一行三元表达式。这种约束放在数据库层的写法,比应用层判断稳得多。
会话有两类状态本质上是「单边」的:
最早的草案我让 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 该有的样子。
撤回功能是必须做的(发错字、发错图、发错人),但实现细节很容易踩坑:
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 把附件丢去后台清理,节省存储「内容清空但记录留下」是私信场景的标准做法。Claude 第一遍就把这逻辑写对了,但 image.purge_later 是我让它加的——它原来写的是 image.purge,那会阻塞响应。
实时这块我让 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,未读数自己刷自己的。
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——一晚上的事。但里面真正稀缺的不是写代码的时间,是把范围、权限、骚扰防护这三件事想清楚的时间。代码写完只是开始,自己用一遍、把毛刺磨掉,才是真的上线。