扩展有个自动回复功能:新会话进来,如果客服还没回过话,就自动发一句首句问候。
整个功能的核心是一个判断:「这个会话里,客服回复过了吗?」
回过了就不发,没回过就发。一行代码的事。
这个判据前后换了四代。前三代都是被宿主系统的改版打穿的——而且每一次失效都是静默的。
第一代:两个计数字段
宿主页面的会话对象上(React fiber 里)有两个计数字段:客服发了几条、客户发了几条。
「客服发过的条数 > 0」——简单、同步、零请求。
直到某次宿主改版,这两个字段被从会话对象上删掉了。
代码里的取值是这样写的:
const agentCount = Number(chat.agentMsgCount) || 0;字段没了,Number(undefined) 是 NaN,|| 0 兜成 0。永远是 0——判据形同虚设,而且不报任何错。
|| 0这种兜底,在字段存在时是健壮性,在字段消失时是掩护。 它把「数据源没了」伪装成了「数据是零」。
第二代:一个时间字段,语义被误读
计数字段没了,会话对象上还有一个看起来很合适的字段——名字大意是「最后回复时间」。
「有最后回复时间 → 回复过」。换上之后,自动回复完全失效了:8 个会话,8 个都判成了「已回复」,一句都不发。
用户的原话是:「修复把响应修没了。」
真机取证后发现,这个字段是「会话最后活动的时刻」,不是「客服最后回复的时刻」:
- 一个客服一条都没发过的会话,它照样非零;
- 客户每说一句话,它就往前跳;
- 抽了 7 个会话比对,只有 1 个等于客服最后一条消息的时间。
字段名没有骗人——「最后回复」在宿主的语义里就是「任何一方的最后回复」。是我按自己的理解读了它。
字段名是别人对数据的描述,不是对你需求的承诺。 用一个字段做判据之前,先找一个「答案已知」的样本去验证它。
第三代:……已经没有字段能回答了
回头把会话对象上所有的标量字段全部 dump 出来逐个看——没有任何一个能回答「客服回过没有」。
这是这一轮最重要的结论:别再试图从页面状态里找这个答案了。
第四代:直接去查消息记录
最后的做法是绕过页面状态,直接查这个会话的消息记录:有没有一条发送方是客服、且不是系统消息的记录。
这是唯一权威的数据源。代价是它变成了异步的,要发一次请求。
异步化之后,四个细节必须做对:
① 缓存不对称。「回复过」是终态,可以永久缓存;「没回复过」随时可能变,只缓存 5 秒——因为新会话的窗口只有两三秒。
② 查询未完成时返回「不确定」,本轮不发。 下一轮再判。宁可晚两秒,也不能在不知道的时候发。
③ 发送前先占位。 消息从发出到落库有一个窗口,这期间再查一次仍然是「没回复过」——会重发。所以发送前先在本地标记「这个会话已经回过了」,堵住这个窗口。
④ ★ 种子必须等结论。 这一条是异步化之后才出现的新 bug。
种子种不上
扩展启动时有一步「播种」:把页面上已经存在的老会话全部标记为「已问候」——它们是扩展启动之前就在那里的,不该补发。
判据是同步的时候,这一步没问题。
判据变成异步之后,播种那一刻所有会话的状态都是「不确定」(查询还在路上)——于是一个都没种上。
几秒后查询结果陆续回来,老会话被判成「没回复过」→ 集体补发。一屏的老会话,每个都收到一句「您好,请问有什么可以帮您」。
修法是播种改成轮询等待,直到每个会话都有了确定结论;在播种完成之前,整轮都不发。
把一个同步判据改成异步,所有依赖它「立刻有答案」的地方都要重新检查。 播种这类只在启动时跑一次的逻辑最容易漏——因为你很少会去看它。
为什么不能直接回滚到第二代
第二代的问题暴露之后,第一反应是「回滚到之前能用的那个」。
实测了一下:7 个会话里有 5 个会被误判为「没回复过」而补发——其中最老的那个已经有 8 条真实的客服消息。
「之前能用」只是因为之前没被仔细看过。
和别人的脚本共存
这个账号上还有一个同事写的服务器脚本在跑:会话进来两三秒后,它也会发一句模板。
用户的决定是:扩展必须独立自动发,不做退让——因为其他装了扩展的人并没有那套脚本。
第四代判据天然处理了共存:谁先发都算「客服回过了」。脚本先发,扩展就不发;扩展先发,脚本那边(如果它也查记录的话)也不会再发。客户不会连收两条。
(消息记录里的消息 id 格式恰好能区分发送方——脚本的、扩展的、系统的、客户的各有各的形态。排查「到底是谁发的」时很有用。)
一个排查陷阱
排查时想在页面 Console 里看一下判据模块的调试状态,结果恒为 undefined。
因为那个调试对象挂在扩展的隔离世界里,页面 Console 默认在主世界,根本看不见它。(这件事单独写过:内容脚本的三个世界。)
这不代表功能坏了。但如果不知道这一层,很容易被它误导成「模块没加载」。
回头看这四代,失效的方式各不相同:
- 第一代:字段被删了,而
|| 0把它掩护成了 0; - 第二代:字段还在,但语义不是我以为的那样;
- 第三代:已经没有字段能回答这个问题了;
- 第四代:数据源对了,但异步化改变了一个启动时的假设。
共同点是:每一次都没有任何东西报错。 功能只是悄悄地变成了「一句都不发」或者「对所有人都发」。
对这类依赖别人系统内部状态的判据,我现在的态度是:它迟早会失效,所以要让它失效的时候能被看见——比如一个「今天自动回复发了 0 条」或者「发了 50 条」的异常提示,比任何精巧的判据都更早发现问题。