这两天在修一个自动回复客服软件(Electron 写的,同时管抖店和微信小店)。

用户反馈的问题看起来都很小:红标不消失、提示音响两声、浮窗显示错字、客户来了不回复、人工接管横幅时有时无。改的过程中发现,这些症状背后几乎都是同一类错误:用「时间」去判断「是不是一条新消息」。

铁律:判断消息,只看消息特征,不看时间

一开始的逻辑是「6 分钟内的消息才算新消息,超过就是历史消息,不回复」。这种写法埋了一堆雷:

  • 客户上午问一句,客服下午才上线 → 消息超过 6 分钟 → 被当成历史消息,永远不回复。
  • 客户 10 分钟后又说一句 → 还是不回复。
  • 反过来,翻「最近联系」看历史会话,旧消息被重新读出来 → 恰好落在窗口内 → 又回复一遍。

时间窗口两头都不讨好。后来全换成消息本身的特征:

判据说明
消息唯一 ID每条消息有服务端的 ID(cmid / msgId)。在「已回复账本」里 = 回过,不在 = 新的
内容指纹去掉时间前缀、数字、标点后比对。同一条消息反复上报,指纹不变
位置判据客户最后一条之后,有没有人回过(answered)。这是页面已经算好的,最可靠
角色特征只认「买家发的、非系统、非提示文本」

时间窗口只留一个用途:清理内存(Map 别无限膨胀)。绝不用它决定回不回复。

坑一:同一份数据读两次,会不同步

有个「发送闸门」,作用是防止给历史消息回复。它自己去读一遍页面的会话列表,判断「这条回过没有」。

问题是:扫描模块已经读过一次列表、判定「要回复」了,闸门又读一次。两次读的时刻不同,数据不一样——扫描说要回,闸门说已回过,于是正常消息被拦掉,客户干等。

这其实是自己给自己挖的坑,代码注释里早就写过「这个不能重复判」,但我重构时又加回去了。

结论:同一份判断,只在一处做。 闸门要么不判,要么就用上游已经判好的结论,别自己再查一遍。

坑二:一个症状,可能有多个信号源

「一条消息响两声」。查日志发现是页面把同一条消息上报了两次:一次是会话列表的预览(文本带 20:48 这种时间前缀),一次是消息区的真实气泡(不带前缀)。两次的会话 ID、文本、条数全不一样,所有按「会话 + 条数」做的去重都失效了。

最后是靠文本指纹永久记忆兜住:把时间前缀和数字都剥掉,「20:48 在吗」和「在吗」的指纹相同,第二次就不响了。

教训:当你有多个地方都能触发同一件事,去重必须放在最底层、用最稳的键,不能指望每个入口自己判。

坑三:删功能比加功能难

中途要删掉一个「红标清除方式」的设置项(因为逻辑改成固定的了)。UI、保存逻辑、后端读取、常量默认值,四处都要删干净。

更麻烦的是数据污染:之前有个 bug 会把「没真正回复过的消息」也记进「已回复账本」。这个账本还落盘了,重启也不消失。表现就是——客户发「在吗」永远不回复,因为系统觉得「这个回过了」。

# 最后只能把落盘的账本删掉,让它重建
rm "C:/Users/Administrator/AppData/Roaming/xxx/engine-state.json"

教训:账本只记真事。为了「省事」把没发生的事记进去,后面要用十倍力气清。

坑四:先看日志,再动手

前几次改完发现问题还在,都是因为凭猜测改。后来养成习惯:先拉当次启动后的日志,grep 关键行。

那次「客户来了不回复」,日志里直接有一行:

[某店铺] 发送闸拦截(直发):no-pending-text → 不发

一眼就知道是哪个闸门拦的,根本不用猜。日志里没有的,才是要查的;日志里有的,直接改就行。

关于「秒级响应」

用户要「客户一发消息就响、就弹窗」。原来响铃挂在页面 DOM 变化上,但飞鸽页面渲染有 1~3 秒延迟。

后来改成在 WebSocket 帧 / HTTP 接口层直接判断:抖店拦下行 WS 帧,微信小店 200ms 轮询接口。数据一到就响,不等页面渲染。这才真正做到秒级。

原则:能拿到原始数据的地方,就别等它渲染成界面。

小结

一个下午的活,回头看全是同一件事:判断「是不是新东西」,要用它的身份,不要用它的时间。

时间是不稳定的(延迟、时区、时钟漂移),身份是稳定的(ID、内容、位置)。凡是能用身份判的,就别用时间。

写到这里,收工。