尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

通知系统归属感设计:如何让用户一眼确认“这是你的解决方案”

通知系统归属感设计:如何让用户一眼确认“这是你的解决方案” 上周处理完一条用户反馈到现在还一直放在我脑子里反复复盘。原话很简单就一句“Notification of Solution doesnt tell me its mine”。翻译过来就是解决方案的通知完全没有告诉我这个解决方案是我自己的。乍一看像是一句英文语法不太完整的抱怨但如果你做过带社交属性、强内容沉淀的平台从源头到尾排查一遍就会发现这句抱怨背后藏着一个几乎所有产品都会踩中的坑当系统推送和 Solution 相关的事件时用户根本分不清这个 Solution 到底是自己的还是别人提交的。我把这个场景放到具体的项目里说。我们当时在做一个在线编程练习平台用户会针对某道题目提交自己的题解也就是 Solution。其他用户可以看到这些题解可以点赞、收藏也可以在下面评论。整个平台的核心资产就是这些题解。用户反馈说收到了一封通知点进去以后才发现通知里提到的那份题解其实是他自己提交的但标题和正文从头到尾没有出现过“你”或者“你的”这种字眼导致他以为是某个他关注的人出了新题解差点误会。后面又连续好几个用户通过工单渠道反馈类似问题我终于意识到这不是文案表达的小事而是通知系统在归属感设计上缺了一整块。这条反馈非常适合用来重新梳理通知系统的设计思路。这篇文章我不打算只讲“改几个字”的层面而是从问题复现、数据结构、事件处理、模板设计到灰度上线的完整过程把通知里“主语是谁、对象是谁、和用户什么关系”这件事掰开揉碎讲清楚。适合正在做社区、工具类、内容类产品的开发者和产品经理参考尤其是那些刚开始搭通知模块、又不想留一堆技术债的人。1. 一条用户反馈背后的真实场景1.1 问题复现通知到底哪里没说清要复现这个问题不需要太复杂的步骤。平台里有两个账号A和BA用户提交了一个题解B用户在这个题解下面评论了一句“这个方法真不错”。按照原来的设计系统会推送给A一条通知通知里大概是这么写的New comment on solution: “双指针移除元素”。用户A收到这条通知后内心是有困惑的。他提交的题解标题叫“双指针移除元素”他记得很清楚。但通知文案没有说“你的题解”只说“solution: 双指针移除元素”。A心里就会打鼓这个solution是不是我提交的那篇是不是别人也写了一篇同名题解他可能还会点进去确认看完之后才放心。问题就出在这个“点进去确认”的额外步骤当这个额外步骤不断重复出现用户就会觉得产品并不理解他甚至会怀疑消息是不是发错了。最初的排查方向其实不是文案而是数据。我们怀疑是不是通知的接收人字段算错了把评论者B的粉丝也带进来了或者是某个订阅逻辑把用户变成了“watcher”而不是“owner”。检查完数据库之后发现接收人计算得很准确A确实收到了属于自己的那条通知。真正的问题在于通知渲染的时候没有把“当前接收通知的用户”和“被评论的Solution”之间的关系带进去。系统只知道自己要给用户A发一条关于solution #1024的评论通知却完全没想过要在文案里告诉用户A他和solution #1024到底是什么关系。这个细节听起来特别小但它会直接影响用户对通知的第一判断。绝大多数人看通知的速度是很快的不会逐字阅读只会扫一眼“谁、什么东西、对我有什么影响”。如果扫了半秒没发现和自己有关这条通知就会被归入“无关推送”甚至直接开启消息忽略模式。1.2 归属歧义的三种典型类型顺着这个思路去翻历史通知记录我把用户产生的歧义分成了三类。第一类叫“所有权歧义”。用户自己创建了Solution比如自己提交的题解通知却没有说“你的”导致用户不确定这篇题解是不是自己的。这是最直接的一种。第二类叫“订阅关系歧义”。用户收藏了别人的题解或者订阅了某个题解的后续讨论。通知只说“Solution 有更新”用户会误以为更新的是自己提交的那篇点进去才发现是别人写的。这种和所有权歧义正好相反是把别人的东西误以为自己的。第三类叫“关联对象歧义”。一个通知同时涉及多个Solution例如批量评论、批量收藏或者用户在评论区被、同时那篇题解自己也收藏过。此时通知里只写了一个Solution的标题但用户不知道到底哪篇才是和自己最有关系的那个。处理不当用户会质疑通知的准确性。这三类问题并非互相独立。我们在真实数据里发现同一条通知可能同时踩中所有权歧义和关联对象歧义。比如用户A既收藏了某题解又在该题解下被那么收到的通知里如果没有明确事件核心对象他真的很容易搞不清状况。1.3 这个细节为什么会直接影响用户信任很多团队会把通知定位成“工具”觉得只要用户能收到、能点进去就算完成任务。但通知在产品里承担的角色其实是“承诺”。你告诉用户发生了什么用户就会基于这条信息判断下一步要不要行为。如果连“这个Solution是我的还是别人的”都说不清楚用户下意识会觉得这个平台的消息不可信进而觉得平台的数据也不可信。我后来回访了反馈这个问题的那位用户他说了一句特别本质的话“我收到通知以后第一反应不是去评论里看说了什么而是在想这到底是不是我自己的东西。”想完这个问题之后他又要再去想“这个和我有什么关系”整套操作下来原本应该很轻快的互动链条被切得非常碎。从后台激活数据也能看到通知点击率并不低但点进来之后的二次停留时间很短说明用户进来主要是为了“确认关系”而不是“消费内容”。这个细节直接改变了我们评估通知效果的方式不再只看点击率还要看点击后有没有发生预期的行为比如评论、编辑、收藏、回访等。2. 通知归属感设计的关键思路2.1 资源所有权与订阅关系必须分开建模要彻底解决“不告诉我是我的”这个问题不是简单地在文案前面加两个字“你的”。背后的核心是通知系统必须知道用户与Solution之间的具体关系类型而这种关系不能靠一次简单的“受众筛选”带出来。我当时重新梳理了平台里所有涉及Solution的事件发现用户和Solution之间至少有三种不同的关系第一条是“owner”指Solution的作者本人第二条是“watcher”指收藏或订阅了该Solution的用户第三条是“participant”指在评论区参与过讨论、但并没有收藏的用户。每次生成通知时系统都要根据接收者重新评估关系而且同一个用户可能同时具备多个身份。比如某用户自己提交了一篇题解同时他也收藏了另一个人的同名题解当他收到关于“双指针移除元素”的评论通知时必须先判断事件里的solution_id是和自己的哪条记录匹配匹配上的是owner状态还是watcher状态才能决定后续文案。我们之前的做法是把“需要通知的用户列表”算出来之后就直接去发送通知完全丢失了“用户是怎么关联到这条通知”的上下文。正确的做法是在生成通知事件时就把relationship字段一并保存在事件记录里。这个字段不需要特别复杂一张枚举表就够owner、watcher、participant、none。但一旦缺失后面所有环节都只能靠猜。数据结构上我给通知表增加了一个列叫relationship_type并且在发送通知之前通过一个专门的服务来解析这个字段。解析逻辑并不复杂但需要保证实时且准确。如果用户在某题解发布后新收藏了那么下一次评论通知就该按照watcher的文案风格走如果用户在收藏后取消了收藏那关系就变成none通知文案也要降级成最普通的形式。这套动态关系判断是解决归属感问题的地基。2.2 通知事件里的“主语、宾语、定语”很多时候通知文案读起来别扭是因为事件描述里缺少了必要成分。一条正常人能看懂的通知至少要有三个东西串联起来谁actor、对什么target object、做了什么action。但在通知归属这件事上还需要一个额外的成分和接收者的关系relationship。用语法来类比这个关系就是“定语”。原来的文案“New comment on solution: 双指针移除元素”其实是有主语、有宾语的但没有定语。改完之后应该是“你的题解收到一条新评论双指针移除元素”。这样接收者不用猜扫一眼就知道是自己提交的题解。而如果是收藏关系就应该是“你收藏的题解收到一条新评论双指针移除元素”。如果是participant关系可以写成“你参与讨论的题解收到一条新评论”。这套思路不仅适用于Solution单个对象也适用于其他有归属属性的实体比如文章、工单、任务、代码仓库。我用一个事件模板公式总结出来【和我的关系】【目标对象】【执行动作】 针对未读内容的一句话预览比如你的题解 被点赞你收藏的题解 有了 3 条新评论你参与讨论的题解 更新了 1 个补充说明如果目标对象有多个还需要在标题前加上“其中一篇”“其中2篇”之类的限定。这个公式看起来简单但我们内部反复测试后才发现真正难的不是定公式而是让后端每次都能把正确的relationship传进前端。2.3 文案结构标准化谁 对什么 做了什么 和你的关系为了不让每个开发都自己发挥文案我整理了一份标准化的通知文案模板表。每一类事件至少准备四种关系版本owner版、watcher版、participant版、none版。这个表格后来成为我们整个团队的文案规范我也建议其他团队一旦确定通知模块就尽早做这个动作。事件类型owner版watcher版participant版none版收到新评论你的题解“标题”收到一条新评论你收藏的题解“标题”收到一条新评论你参与讨论的题解“标题”收到一条新评论题解“标题”收到一条新评论收到点赞你的题解“标题”获得一次点赞你收藏的题解“标题”获得一次点赞你参与讨论的题解“标题”获得一次点赞题解“标题”获得一次点赞收藏数达到里程碑你的题解“标题”收藏数突破100不可用不可用不可用被你的题解“标题”下的评论有人你你收藏的题解“标题”下的评论有人你你参与讨论的题解“标题”下的评论有人你有人在一篇题解下你这里最需要强调的是not all events apply to all relationships。如果用户只是观看题解从未参与那条“收藏数突破里程碑”的通知就不该发给这个用户。也就是说文案模板表不只是文案的事它还决定了接收人的范围能帮助砍掉一大部分无关通知。模板标准化之后前端的展示逻辑也顺手清理了一轮。原来自己拼消息、自己造文案的地方全部改成调用后端渲染好的纯文本避免因为前端状态不一致导致显示错误。这对移动端推送和邮件通知尤其重要因为用户往往在锁屏上只扫一眼摘要一句“你的题解”和“题解”带来的感受天差地别。3. 从反馈到落地的完整改造实录3.1 梳理事件流当前通知是怎么生成的我做的第一件事不是改代码而是把整条通知链路画出来。不画mermaid就是一张朴素的时序流程从评论产生开始到事件入库、筛选订阅者、渲染文案、推送最后到达客户端。原流程大致是这样的用户在题解下创建一条评论评论保存到数据库后端服务去查哪些用户需要收到通知作者、收藏者、参与者生成通知标题和正文写入通知表发送Push/邮件问题出在第4步生成文案的时候只拿solution.title拼接字符串完全没有判断接收者身份。也就是说代码虽然知道接收者是谁但并没有把“接收者”和“solution”之间的关系作为一种输入传给渲染函数。所以不管是谁收看到的都是同一句话。这个问题的本质是事件流中丢失了上下文。评论事件本身只记录了actor和target但通知是发给多个不同身份的接收者的接收者与target的关系必须单独解析。我在事件流里增加了一个环节在渲染文案之前先调用一个get_user_solution_relationship(user_id, solution_id)函数返回关系枚举。根据关系枚举再决定使用哪套模板。那种“先查接收人列表再批量发相同文案”的做法建议彻底移除。凡是一条通知对应多个用户关系的场景必须循环逐用户渲染。短时性能压力会高一点但换来的准确性是值的。后来我们用缓存和批量接口把这部分优化了不少但核心逻辑一直保持了“因人而异”的方式。3.2 改进模板从“Solution”到“你的Solution”在代码动工前我们先把文案改了。最原始的文案是New comment on solution: {solution_title}这句话的问题是“solution”前没有任何限定词。知道自己的题解被评论本应是强正向反馈结果因为用户还要花时间确认ownership情绪就变平了。新文案用占位符区分关系我拿Django模板举例模板文件像下面这样写{% if relationship owner %} 你的题解「{{ solution.title }}」收到一条新评论 {% elif relationship watcher %} 你收藏的题解「{{ solution.title }}」收到一条新评论 {% elif relationship participant %} 你参与讨论的题解「{{ solution.title }}」收到一条新评论 {% else %} 题解「{{ solution.title }}」收到一条新评论 {% endif %}如果通知事件是点赞则把“收到一条新评论”替换成“获得一次点赞”。如果事件是收藏数达标则只有owner版会输出文案其他关系在发送前就过滤掉了。这里还处理了一个很容易被忽略的细节当solution.title特别长的时候不要把完整标题放在通知里。我们会做截断比如显示开头20个字符加省略号。否则一条推送在锁屏上被截断正好把“你的题解”截掉等于白改。3.3 后端实现事件序列化时带上ownership上下文文案改完之后后端数据结构也必须跟上。我给通知事件类增加了一个字段叫relationship。在生成通知时解析出来并写入存储。实现上我建议把事件生成和渲染拆成两个函数第一个负责构造事件上下文第二个负责把上下文渲染成展示文案。下面是一段简化的Python代码class NotificationBuilder: def __init__(self, user_id, solution, event_type): self.user_id user_id self.solution solution self.event_type event_type self.relationship self._resolve_relationship() def _resolve_relationship(self): if self.solution.owner_id self.user_id: return owner if SolutionWatch.objects.filter(user_idself.user_id, solutionself.solution).exists(): return watcher if SolutionParticipant.objects.filter(user_idself.user_id, solutionself.solution).exists(): return participant return none def build_message(self): context { solution: self.solution, relationship: self.relationship, } template self._get_template() return template.render(context)渲染模板的逻辑单独放在一个服务里这样文案要改的时候不需要去动事件生成的逻辑。每次有用户反馈“通知没说清楚”第一件事就是查模板而不是查代码。在实际落地时我们额外做了一个历史数据补偿对于改版前已经生成的存量通知如果还是旧文案不再重新生成推送而是在用户打开通知中心列表时通过动态的JS逻辑自动加上“你的”标签。这个方案虽然不算特别优雅但避免了一次大规模数据迁移。3.4 灰度验证用旧文案和新文案做对比数据改完之后我们没有立刻全量发布而是做了两轮灰度。第一轮是内部原语测试团队成员用测试账号分别以owner、watcher、participant三种身份触发评论通知确认文案都正确。第二轮是2%流量灰度观察通知点击率、通知进入后的留存时长、以及用户反馈量。灰度数据出来后结论非常明显。新文案的通知点击率提升了大约6个百分点看起来不多但进入通知详情之后的平均停留时长提升了接近20%。这说明用户点击通知后不再是“确认这条是不是我的”而是直接进入内容阅读行为路径更顺了。还有一个有意思的变化灰度的两周内关于“这个不是我的题解吧”这类工单基本清零了。之前虽然量不大但每周都会有两三例。这让我意识到通知里的一句话不只是一个展示问题它会被用户放大成产品是否有用心在替他思考的问题。针对灰度过程中对旧文案的对比我们做了一个简单的A/B测试分了对照组和实验组。对照组用旧文案“New comment on solution”实验组用新文案“你的题解收到一条新评论”。最终实验组整体点击率高出8%。这个数据支撑了我们后来把文案固化到所有渠道包括站内信和邮件模板。4. 常见问题与排查技巧4.1 存量历史通知要不要改这是一个被问过很多次的问题。准确说已经发出去的历史通知如果用户没读过你改数据库里的文案并不能保证客户端已经展示的那条跟着变。我们的建议是不要为了统一而强行去改历史通知。理由非常简单历史通知是用户之前看到的快照如果你事后改掉用户会以为自己记错了反而降低信任感。更好的做法是在用户打开通知中心时通过前端逻辑把“未读”的历史通知做一层高亮比如在标题前加一个小圆点让用户重点关注新产生的通知。至于那些已经读过的历史通知就让它保留原样不去动。4.2 多身份关系重叠时怎么表达前面提到过一个用户可能同时是题解的owner也同时订阅了其他人相同的题解。当一条通知涉及多个Solution或者多个关系时需要确定一个优先顺序。我采用的优先级是提及 直接评论 点赞 收藏 被围观。简单说表达“和你关系最深”的那个事件。如果两条事件的关系层级一样比如都是别人评论了你的题解那就把最近一条作为展示摘要。这种重叠场景在代码里不好用硬编码解决我建议在NotificationBuilder里维护一个关系优先级列表RELATIONSHIP_PRIORITY [owner, watcher, participant, none] def get_primary_relationship(user, solutions): # 找出该用户在多个solution中的最高优先级关系 relationships [fetch_relationship(user, s) for s in solutions] ranked sorted(relationships, keylambda r: RELATIONSHIP_PRIORITY.index(r)) return ranked[0]这段逻辑虽然简单但在真正出现多条事件时能帮你快速决策避免后来人反复看半天也不知道为什么文案选了某一种。4.3 多语言环境下“你的”怎么处理如果产品覆盖多语言直接拼接“你的”会有问题。中文的“你的题解”很自然但英文如果写成“Your solution”在很多场景下会显得太啰嗦甚至有些冒犯日语和韩语里敬语体系又不同。最优解法是不要把文案拆成半句在前端拼起来而是每种事件定义一套完整的本地化模板。例如中文本地化可能是“你的题解「标题」收到一条新评论”英文可以是“New comment on your solution: title”。两个句子结构完全不同但都表达了关系。如果试图用英文直译“你的题解”再拼接标题反而会造出“Your solution: title new comment”这种不伦不类的东西。所以模板要按语言整体翻译不能只翻译情感词。平台资源有限的时候至少先保证中文和英文两套模板都是独立完整的不要共用一套结构。这是我们在做国际化过程中踩过的最大坑。4.4 通知频率与触达渠道的组合坑通知归属感改好以后还要小心频率问题。如果用户同时收到邮件、站内信、App Push三条通知即使每条都正确写了“你的题解”用户依然会觉得被打扰。尤其是owner身份的题解被评论理论上是一次高价值互动但如果一分钟内被评论了10次用户会收到10条近乎一样的通知体验会直接从惊喜变成骚扰。后来我们加了聚合逻辑同一篇solution在5分钟内的多条评论合并成一条通知文案变成“你的题解「标题」收到5条新评论”。这样通知仍然有明确的归属感而且信息量更足。注意这里的聚合只针对同一种关系不会把owner和watcher的通知一起合并否则又会产生新的歧义。渠道上也要分优先级站内信是默认保留邮件通知建议默认关闭Push只推送owner和提及两类强互动事件。其他watcher、participant关系产生的事件只进站内信列表。用这个策略内测了两周主动关闭通知的用户比例明显下降。5. 后续还能怎么扩展5.1 把归属感带进通知中心页面改完文案之后我又顺手把通知中心页面也优化了一遍。原来通知列表里的每一条都是纯文本没有图标区分用户很难一眼看出哪些是和自己作品相关的消息。后来我们把每一条通知的类型icon做了分级owner事件用一个明显的“我的”标记watcher事件用另一个标记participant事件不加标记。这个视觉分层虽然小但配合文案用户记忆成本更低。同时通知中心增加了“只看与我相关”的筛选按钮。点击之后列表只展示owner和提及类型的通知适合那些时间少、只想看关键动态的用户。从后台筛选率看有17%的用户主动使用过这个按钮说明需求是真实的。5.2 个性化通知规则与静音策略站在用户视角不是所有人都希望收到所有类型的通知。我给设置页增加了“通知偏好”面板用户可以单独配置是否接收owner评论通知、是否接收watcher收藏动态通知、是否接收participant讨论通知。设置项看起来简单但涉及后端事件过滤全局查询的时候一定要把偏好表join进去。我加了这样一个函数用来判断某个用户是否需要接收某种关系的事件def should_notify(user, event_type, relationship): pref NotificationPreference.objects.get(useruser) mapping { owner_comment: pref.owner_comment, watcher_comment: pref.watcher_comment, participant_comment: pref.participant_comment, } return mapping.get(f{relationship}_{event_type}, True)这里的映射规则最好在模板表里同步保存一份如果前端和后端没有对上号会出现“用户明明关了后台还在发”的情况投诉率会直线上升。5.3 我踩过的最值得说的一个坑最后分享一下这个改造过程中我踩过的最深的一个坑。我们当时为了“文案不一致会带来短信费用浪费”为了省事做了一个很“聪明”的决定在评论产生后立刻对所有接收者用同一句文案去生成推送等到用户实际打开时再动态替换成正确的文案。结果上线后出现了非常诡异的现象App Push消息正确显示“你的题解”但用户点进去以后通知中心因为缓存问题展示成了“题解收到评论”。这一下子就造成了新旧文案不一致用户感觉我们是在乱改。排查了很久才定位到问题原因是Push在生成时没有等到后续请求解析relationship而通知中心在渲染时用了一个过期的缓存模板。后来我把通知生成做到一个统一的入口不管走Push还是站内信都在同一处渲染文案并写入存储。虽然没有完全避免缓存问题但至少所有渠道都使用同一份数据不会出现渠道间文案不一致的情况。这些经验给我最深的感受是通知里的一句话背后真的是一整套关系建模、事件渲染和用户体验的工程问题。下次再有人给你提一条类似“Notification of Solution doesnt tell me its mine”的反馈先别急着改文案回头看看系统里有没有把“和我的关系”好好地传到底。
返回列表