<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>飞天造物手记</title><link>https://roper.cool/</link><description>Recent content on 飞天造物手记</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><lastBuildDate>Wed, 27 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://roper.cool/index.xml" rel="self" type="application/rss+xml"/><item><title>微信小程序后端接口设计备忘：从登录态、幂等到灰度发布</title><link>https://roper.cool/posts/small-program-backend-notes/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://roper.cool/posts/small-program-backend-notes/</guid><description>&lt;p&gt;最近在规划一个自研微信小程序时，我把后端接口层重新梳理了一遍。项目规模不大，但越是个人项目，越容易因为“先跑起来再说”而把一些基础约定欠下来。等功能多了，再回头补登录态、幂等、审计和灰度，成本会变得很高。&lt;/p&gt;
&lt;p&gt;这篇是我给自己写的一份备忘，目标不是追求大而全，而是用适合个人开发节奏的方式，先把最容易失控的部分定住。&lt;/p&gt;
&lt;h2 id="1-登录态不要只停留在能拿到-openid"&gt;&lt;a href="#1-%e7%99%bb%e5%bd%95%e6%80%81%e4%b8%8d%e8%a6%81%e5%8f%aa%e5%81%9c%e7%95%99%e5%9c%a8%e8%83%bd%e6%8b%bf%e5%88%b0-openid" class="header-anchor"&gt;&lt;/a&gt;1. 登录态不要只停留在“能拿到 openid”
&lt;/h2&gt;&lt;p&gt;很多小程序后端的第一步，是前端调用 &lt;code&gt;wx.login()&lt;/code&gt; 拿到 code，然后服务端去微信侧换取 &lt;code&gt;openid&lt;/code&gt; 和 &lt;code&gt;session_key&lt;/code&gt;。这个流程本身没问题，但如果系统设计停在“库里有个 openid 字段”，后面就会遇到几个问题：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;无法区分设备会话。&lt;/li&gt;
&lt;li&gt;登录态刷新策略模糊。&lt;/li&gt;
&lt;li&gt;服务端日志里缺少可追踪的用户会话标识。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我现在更倾向于把微信登录理解为“身份证明的上游输入”，而不是最终会话本身。服务端在拿到 openid 后，仍然要自己签发应用侧 session token，并且把 token 生命周期、刷新机制、失效策略设计清楚。&lt;/p&gt;
&lt;p&gt;对个人项目来说，不一定要一上来就做复杂的 OAuth 体系，但至少要做到：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;应用会话与微信 code 交换流程解耦。&lt;/li&gt;
&lt;li&gt;token 过期时间可配置。&lt;/li&gt;
&lt;li&gt;敏感接口在服务端统一校验，不依赖前端状态判断。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="2-写接口时尽早考虑幂等"&gt;&lt;a href="#2-%e5%86%99%e6%8e%a5%e5%8f%a3%e6%97%b6%e5%b0%bd%e6%97%a9%e8%80%83%e8%99%91%e5%b9%82%e7%ad%89" class="header-anchor"&gt;&lt;/a&gt;2. 写接口时尽早考虑幂等
&lt;/h2&gt;&lt;p&gt;我以前做小程序时，最容易忽视的一件事就是重复点击。移动网络环境比桌面网页更容易出现超时重试、弱网抖动和用户反复点击提交按钮的情况。如果创建订单、保存记录、发起任务这些操作没有幂等保护，就很容易出现重复数据。&lt;/p&gt;
&lt;p&gt;这次我给自己定的规则很简单：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;只要是“有副作用”的写操作，都要先问一句：它是否可能被重复提交？&lt;/li&gt;
&lt;li&gt;如果答案是可能，就给它设计幂等键。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;幂等键不一定非要上复杂中间件。个人项目起步阶段，用请求头传一个 &lt;code&gt;Idempotency-Key&lt;/code&gt;，配合数据库唯一约束和短期结果缓存，就能解决大部分问题。重点是思维上先意识到“重复请求不是边缘情况，而是默认要处理的情况”。&lt;/p&gt;
&lt;h2 id="3-返回结构统一比字段名优雅更重要"&gt;&lt;a href="#3-%e8%bf%94%e5%9b%9e%e7%bb%93%e6%9e%84%e7%bb%9f%e4%b8%80%e6%af%94%e5%ad%97%e6%ae%b5%e5%90%8d%e4%bc%98%e9%9b%85%e6%9b%b4%e9%87%8d%e8%a6%81" class="header-anchor"&gt;&lt;/a&gt;3. 返回结构统一比字段名优雅更重要
&lt;/h2&gt;&lt;p&gt;个人项目常见的演进路径是：先写一个接口，能返回数据就行；第二个接口复制前一个；第三个接口开始出现不同风格的错误码和 message 字段。等前端页面多起来，联调成本会明显上升。&lt;/p&gt;
&lt;p&gt;所以我这次先把返回包格式固定下来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;requestId&lt;/code&gt;：用于日志追踪。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;code&lt;/code&gt;：业务状态码。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;message&lt;/code&gt;：给前端展示或记录的可读信息。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;data&lt;/code&gt;：真正业务数据。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;哪怕前期只有自己一个人开发，这种统一也很有价值。因为几周之后再回来看代码，“未来的自己”其实就已经像另一个协作者了。&lt;/p&gt;
&lt;h2 id="4-灰度和配置开关比想象中更早需要"&gt;&lt;a href="#4-%e7%81%b0%e5%ba%a6%e5%92%8c%e9%85%8d%e7%bd%ae%e5%bc%80%e5%85%b3%e6%af%94%e6%83%b3%e8%b1%a1%e4%b8%ad%e6%9b%b4%e6%97%a9%e9%9c%80%e8%a6%81" class="header-anchor"&gt;&lt;/a&gt;4. 灰度和配置开关比想象中更早需要
&lt;/h2&gt;&lt;p&gt;以前我总觉得灰度发布是大团队才需要的能力。后来发现只要产品要上线给真实用户用，即使只有几十个用户，灰度依然非常有帮助。&lt;/p&gt;
&lt;p&gt;最简单的灰度能力可以从配置中心思维开始，而不一定是完整平台：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;某个功能是否对所有用户开放。&lt;/li&gt;
&lt;li&gt;某个实验参数是否分组生效。&lt;/li&gt;
&lt;li&gt;某个接口是否暂时降级到旧逻辑。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果把这些判断都硬编码在业务逻辑里，后面会很难收拾。哪怕只是做一个数据库配置表，或者一份受控的远程配置，也比每次上线改代码再发版灵活很多。&lt;/p&gt;
&lt;h2 id="5-审计与日志从第一天就值得做"&gt;&lt;a href="#5-%e5%ae%a1%e8%ae%a1%e4%b8%8e%e6%97%a5%e5%bf%97%e4%bb%8e%e7%ac%ac%e4%b8%80%e5%a4%a9%e5%b0%b1%e5%80%bc%e5%be%97%e5%81%9a" class="header-anchor"&gt;&lt;/a&gt;5. 审计与日志从第一天就值得做
&lt;/h2&gt;&lt;p&gt;个人项目往往没有专门的运维体系，所以出问题时更依赖日志。我的经验是，日志不需要特别花哨，但一定要在关键链路上可串联。&lt;/p&gt;
&lt;p&gt;我现在至少会记录这些信息：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;requestId&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;用户标识或匿名会话标识&lt;/li&gt;
&lt;li&gt;接口耗时&lt;/li&gt;
&lt;li&gt;核心参数摘要&lt;/li&gt;
&lt;li&gt;下游调用结果&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;另外，对管理后台操作、配置变更、任务重试这类动作，我会额外留审计日志。原因很简单：一旦某条数据异常，先弄清“是谁在什么时候以什么方式改过它”，排查效率会高很多。&lt;/p&gt;
&lt;h2 id="6-当前阶段最适合个人项目的取舍"&gt;&lt;a href="#6-%e5%bd%93%e5%89%8d%e9%98%b6%e6%ae%b5%e6%9c%80%e9%80%82%e5%90%88%e4%b8%aa%e4%ba%ba%e9%a1%b9%e7%9b%ae%e7%9a%84%e5%8f%96%e8%88%8d" class="header-anchor"&gt;&lt;/a&gt;6. 当前阶段最适合个人项目的取舍
&lt;/h2&gt;&lt;p&gt;最后总结一下，我认为个人开发的小程序后端在第一阶段最值得优先做的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;登录态独立成应用会话。&lt;/li&gt;
&lt;li&gt;写操作具备基本幂等能力。&lt;/li&gt;
&lt;li&gt;返回结构统一。&lt;/li&gt;
&lt;li&gt;关键日志可追踪。&lt;/li&gt;
&lt;li&gt;留出配置开关和灰度空间。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这些工作看起来不像业务功能那样“立刻可见”，但它们直接决定项目后面是轻松迭代，还是每加一个功能都担心把旧逻辑带崩。对长期维护的网站和后续小程序、小游戏来说，这些地基越早打，收益越大。&lt;/p&gt;</description></item><item><title>在 Monorepo 里统一 TypeScript 路径别名：一次 Vite、TS Server 与测试工具的对齐记录</title><link>https://roper.cool/posts/vite-alias-and-monorepo/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://roper.cool/posts/vite-alias-and-monorepo/</guid><description>&lt;p&gt;最近整理一个前端 Monorepo 时，我又踩到了那个很典型的问题：编辑器里 &lt;code&gt;@shared/utils&lt;/code&gt; 能正常跳转，类型提示也没问题，但一到构建阶段，Vite 就报模块找不到。更糟糕的是，测试环境和本地开发环境表现还不一致。&lt;/p&gt;
&lt;p&gt;这种问题的根源通常不是某一个工具配置错了，而是“我们以为只有一个别名系统，实际上每个工具都在各自解析路径”。&lt;/p&gt;
&lt;h2 id="1-typescript-的-paths-只解决类型系统不自动影响运行时"&gt;&lt;a href="#1-typescript-%e7%9a%84-paths-%e5%8f%aa%e8%a7%a3%e5%86%b3%e7%b1%bb%e5%9e%8b%e7%b3%bb%e7%bb%9f%e4%b8%8d%e8%87%aa%e5%8a%a8%e5%bd%b1%e5%93%8d%e8%bf%90%e8%a1%8c%e6%97%b6" class="header-anchor"&gt;&lt;/a&gt;1. TypeScript 的 paths 只解决类型系统，不自动影响运行时
&lt;/h2&gt;&lt;p&gt;这是最容易被忽略的点。&lt;code&gt;tsconfig.json&lt;/code&gt; 里的 &lt;code&gt;compilerOptions.paths&lt;/code&gt; 本质上是告诉 TypeScript：&lt;/p&gt;
&lt;p&gt;“当你做类型检查和模块解析推断时，可以把这个别名映射到这些目录。”&lt;/p&gt;
&lt;p&gt;它并不会自动修改 Vite、Node.js、Vitest、Jest、ESLint 或打包产物里的真实导入路径。&lt;/p&gt;
&lt;p&gt;所以当我看到编辑器一切正常时，我一度误以为别名已经配置完成。实际上只是 TS Server 帮我“看懂了”，真正运行代码的工具链并没有同步知道这层映射。&lt;/p&gt;
&lt;h2 id="2-monorepo-场景更容易失配"&gt;&lt;a href="#2-monorepo-%e5%9c%ba%e6%99%af%e6%9b%b4%e5%ae%b9%e6%98%93%e5%a4%b1%e9%85%8d" class="header-anchor"&gt;&lt;/a&gt;2. Monorepo 场景更容易失配
&lt;/h2&gt;&lt;p&gt;单仓库多包结构下，问题会进一步放大，因为常见目录形态是这样的：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;apps/web&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apps/admin&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;packages/shared&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;packages/ui&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;如果根目录有一份 &lt;code&gt;tsconfig.base.json&lt;/code&gt;，子应用又各自 &lt;code&gt;extends&lt;/code&gt; 它，那么路径别名经常在“类型层面共享成功”，但具体打包器是在每个应用目录启动的。此时工具解析根相对路径、工作目录、软链接包、源码入口都可能产生分歧。&lt;/p&gt;
&lt;p&gt;我后来统一的原则是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;根层定义类型语义。&lt;/li&gt;
&lt;li&gt;每个运行时工具都显式继承或读取同一份映射。&lt;/li&gt;
&lt;li&gt;优先让包通过标准导出工作，路径别名只作为开发体验增强，而不是跨包依赖的唯一方式。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="3-先问自己你是在做包依赖还是源码别名"&gt;&lt;a href="#3-%e5%85%88%e9%97%ae%e8%87%aa%e5%b7%b1%e4%bd%a0%e6%98%af%e5%9c%a8%e5%81%9a%e5%8c%85%e4%be%9d%e8%b5%96%e8%bf%98%e6%98%af%e6%ba%90%e7%a0%81%e5%88%ab%e5%90%8d" class="header-anchor"&gt;&lt;/a&gt;3. 先问自己：你是在做“包依赖”还是“源码别名”？
&lt;/h2&gt;&lt;p&gt;这一步非常关键。我把依赖关系分成两类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;业务应用依赖公共包，例如 &lt;code&gt;@roper/shared&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;应用内部为了减少相对路径层级，用 &lt;code&gt;@/components&lt;/code&gt; 指向本地源码目录。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这两类需求不要混在一起。&lt;/p&gt;
&lt;p&gt;如果是第一类，最好走标准 package 导出，也就是在 &lt;code&gt;packages/shared/package.json&lt;/code&gt; 中声明 &lt;code&gt;name&lt;/code&gt;、&lt;code&gt;exports&lt;/code&gt;、&lt;code&gt;types&lt;/code&gt; 等，让应用像依赖正常 npm 包一样依赖它。这样打包器、测试器、IDE 对它的认知会更一致。&lt;/p&gt;
&lt;p&gt;如果是第二类，本地源码别名才适合用 &lt;code&gt;paths&lt;/code&gt; 加 &lt;code&gt;resolve.alias&lt;/code&gt;。这种别名的边界应该尽量局限在单个应用内部，避免跨应用、跨包互相引用源码目录。&lt;/p&gt;
&lt;h2 id="4-vite-与-vitest-需要明确共享解析配置"&gt;&lt;a href="#4-vite-%e4%b8%8e-vitest-%e9%9c%80%e8%a6%81%e6%98%8e%e7%a1%ae%e5%85%b1%e4%ba%ab%e8%a7%a3%e6%9e%90%e9%85%8d%e7%bd%ae" class="header-anchor"&gt;&lt;/a&gt;4. Vite 与 Vitest 需要明确共享解析配置
&lt;/h2&gt;&lt;p&gt;我最初只在 &lt;code&gt;vite.config.ts&lt;/code&gt; 里写了 &lt;code&gt;resolve.alias&lt;/code&gt;，结果 &lt;code&gt;vitest&lt;/code&gt; 虽然能复用一部分配置，但在某些拆分配置场景下仍然没有完整继承，最后表现成“页面能跑，测试却找不到模块”。&lt;/p&gt;
&lt;p&gt;后来我做了两件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;把路径映射抽到一个共享函数里。&lt;/li&gt;
&lt;li&gt;明确让 Vite 与 Vitest 都读取同一份解析规则。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这样做的价值不只是少写几行配置，而是降低“有的人改了构建配置，却忘了同步测试配置”的概率。路径别名这种基础设施类配置，越分散越容易慢慢漂移。&lt;/p&gt;
&lt;h2 id="5-node-运行脚本时要额外留心"&gt;&lt;a href="#5-node-%e8%bf%90%e8%a1%8c%e8%84%9a%e6%9c%ac%e6%97%b6%e8%a6%81%e9%a2%9d%e5%a4%96%e7%95%99%e5%bf%83" class="header-anchor"&gt;&lt;/a&gt;5. Node 运行脚本时要额外留心
&lt;/h2&gt;&lt;p&gt;很多团队会在仓库里保留一些直接用 Node 执行的脚本，比如代码生成、数据迁移、构建辅助命令。此时如果脚本源码里也用了 TypeScript 别名，问题会再次出现，因为裸 Node 并不认识 &lt;code&gt;tsconfig.paths&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这时候通常有三种选择：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;构建后再执行，让打包器或转译器处理掉路径。&lt;/li&gt;
&lt;li&gt;使用支持别名解析的运行器。&lt;/li&gt;
&lt;li&gt;干脆让这类脚本只使用相对路径或 package 名称，避免再引一层复杂度。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我最后倾向第三种。工程中有些地方值得抽象，有些地方最好老老实实朴素一点，维护成本反而更低。&lt;/p&gt;
&lt;h2 id="6-这次整理后我保留的实践"&gt;&lt;a href="#6-%e8%bf%99%e6%ac%a1%e6%95%b4%e7%90%86%e5%90%8e%e6%88%91%e4%bf%9d%e7%95%99%e7%9a%84%e5%ae%9e%e8%b7%b5" class="header-anchor"&gt;&lt;/a&gt;6. 这次整理后我保留的实践
&lt;/h2&gt;&lt;p&gt;最后落下来的做法如下：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;根 &lt;code&gt;tsconfig.base.json&lt;/code&gt; 只放通用编译选项与跨项目共享的基础别名。&lt;/li&gt;
&lt;li&gt;应用内别名统一用 &lt;code&gt;@/*&lt;/code&gt; 指向自身 &lt;code&gt;src/*&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;跨包引用优先使用 package name 与 exports，不直接别名到另一个包的 &lt;code&gt;src&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;Vite、Vitest、ESLint 解析器尽量复用同一份路径信息。&lt;/li&gt;
&lt;li&gt;能不用别名的简单脚本就不用别名。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;整理完之后，最明显的收益其实不是“错误没了”，而是新同事进入项目时更容易理解：什么场景该用包导出，什么场景只是为了避免 &lt;code&gt;../../../&lt;/code&gt;。一套约定如果能被快速解释清楚，往往才是真正稳定的约定。&lt;/p&gt;</description></item><item><title>WebSocket 重连策略实践：把“看起来在线”变成“真正可恢复”</title><link>https://roper.cool/posts/websocket-reconnect/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://roper.cool/posts/websocket-reconnect/</guid><description>&lt;p&gt;最近在做一个用于展示实时构建日志的小工具。最开始我图省事，浏览器端只要 &lt;code&gt;socket.onclose = () =&amp;gt; reconnect()&lt;/code&gt; 就算完成。结果上线到测试环境后，问题很快出现了：办公室网络偶发抖动、电脑休眠后恢复、切换 VPN、浏览器标签页长时间后台挂起，都会把长连接带到一个半死不活的状态。页面上还能看到“已连接”，但日志已经不再滚动。&lt;/p&gt;
&lt;p&gt;这次复盘让我意识到，WebSocket 的困难从来不在“连上”，而在“如何知道它已经坏了，以及坏了之后怎样恢复得足够稳”。&lt;/p&gt;
&lt;h2 id="1-先区分几类失败"&gt;&lt;a href="#1-%e5%85%88%e5%8c%ba%e5%88%86%e5%87%a0%e7%b1%bb%e5%a4%b1%e8%b4%a5" class="header-anchor"&gt;&lt;/a&gt;1. 先区分几类失败
&lt;/h2&gt;&lt;p&gt;我先把现场抓到的失败类型做了分类：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;服务端主动关闭。通常会有 close code，可以明确判断是鉴权失败、版本不兼容，还是服务端发布导致断开。&lt;/li&gt;
&lt;li&gt;网络瞬断。客户端有时能收到 &lt;code&gt;close&lt;/code&gt;，有时什么事件都没有，只是消息不再到达。&lt;/li&gt;
&lt;li&gt;浏览器休眠或后台节流。定时器和心跳都可能被延后，导致误判。&lt;/li&gt;
&lt;li&gt;连接恢复但状态丢失。比如服务端增量推送的游标没有同步，重连后会漏消息。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;把这些情况分开看之后，重连策略就清晰很多：不是所有断开都该“立刻重连”，不是所有重连成功都代表业务恢复。&lt;/p&gt;
&lt;h2 id="2-最小可用方案心跳--指数退避"&gt;&lt;a href="#2-%e6%9c%80%e5%b0%8f%e5%8f%af%e7%94%a8%e6%96%b9%e6%a1%88%e5%bf%83%e8%b7%b3--%e6%8c%87%e6%95%b0%e9%80%80%e9%81%bf" class="header-anchor"&gt;&lt;/a&gt;2. 最小可用方案：心跳 + 指数退避
&lt;/h2&gt;&lt;p&gt;我给客户端补了两层最基础的保护。&lt;/p&gt;
&lt;p&gt;第一层是应用级心跳。浏览器原生 WebSocket API 并不直接暴露 ping/pong，所以我在业务协议里加了一个 &lt;code&gt;type = heartbeat&lt;/code&gt; 的消息，每 20 秒发送一次。服务端原样返回即可。&lt;/p&gt;
&lt;p&gt;第二层是指数退避。以前我每次断开就 1 秒后重试，结果网络稍差时会疯狂重连，把日志刷满。改成 1s、2s、4s、8s、16s，上限 30s 后，客户端和服务端都平稳很多。为了避免所有客户端同时重连，我又加了一个 0 到 800ms 的随机抖动。&lt;/p&gt;
&lt;p&gt;这里有个细节：心跳超时不能只看“多久没收到任何消息”，还要看当前页面是否处于后台。如果标签页被浏览器严重节流，原本 20 秒的心跳定时器可能推迟到一分钟以后才执行。我的做法是，在 &lt;code&gt;visibilitychange&lt;/code&gt; 里记录页面状态，后台时只维持弱监测，回到前台后立即做一次快速探活。&lt;/p&gt;
&lt;h2 id="3-真正麻烦的是假活着"&gt;&lt;a href="#3-%e7%9c%9f%e6%ad%a3%e9%ba%bb%e7%83%a6%e7%9a%84%e6%98%af%e5%81%87%e6%b4%bb%e7%9d%80" class="header-anchor"&gt;&lt;/a&gt;3. 真正麻烦的是“假活着”
&lt;/h2&gt;&lt;p&gt;最难排查的不是彻底断开，而是 TCP 连接还没完全释放、前端实例也没报错，但实际上消息链路已经断了。这种情况下，&lt;code&gt;readyState&lt;/code&gt; 仍然可能是 &lt;code&gt;OPEN&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我最后采用的判断标准是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;最近一次收到任意服务端消息的时间。&lt;/li&gt;
&lt;li&gt;最近一次收到心跳回包的时间。&lt;/li&gt;
&lt;li&gt;当前是否存在连续多次发送失败或解析异常。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;当这三项组合起来超过阈值时，我会主动 &lt;code&gt;close()&lt;/code&gt; 当前连接，并进入受控重连，而不是继续相信这个“看似在线”的 socket。&lt;/p&gt;
&lt;p&gt;这个策略的好处是主动。与其等待浏览器底层某个时刻终于意识到连接坏了，不如业务层自己做出“此连接不可信”的判定。&lt;/p&gt;
&lt;h2 id="4-重连不等于恢复"&gt;&lt;a href="#4-%e9%87%8d%e8%bf%9e%e4%b8%8d%e7%ad%89%e4%ba%8e%e6%81%a2%e5%a4%8d" class="header-anchor"&gt;&lt;/a&gt;4. 重连不等于恢复
&lt;/h2&gt;&lt;p&gt;我之前另一个误区是，只要重新连上就把 UI 改成绿色“已连接”。后来发现这会掩盖更深层的问题：实时系统真正要恢复的是“数据连续性”。&lt;/p&gt;
&lt;p&gt;所以我把连接状态拆成了三层：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Transport Connected：底层 WebSocket 已建立。&lt;/li&gt;
&lt;li&gt;Session Ready：鉴权、订阅、房间加入等初始化动作完成。&lt;/li&gt;
&lt;li&gt;Stream Healthy：消息序列号连续，业务流恢复正常。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;只有第三层成立，我才让页面展示“实时同步中”。如果只是第一层成功，页面会显示“连接已恢复，正在补齐数据”。&lt;/p&gt;
&lt;p&gt;这套状态分层非常值钱，因为它把技术状态翻译成了用户能理解的提示，也方便我在日志里精确定位问题卡在哪一层。&lt;/p&gt;
&lt;h2 id="5-消息补偿是最后一块拼图"&gt;&lt;a href="#5-%e6%b6%88%e6%81%af%e8%a1%a5%e5%81%bf%e6%98%af%e6%9c%80%e5%90%8e%e4%b8%80%e5%9d%97%e6%8b%bc%e5%9b%be" class="header-anchor"&gt;&lt;/a&gt;5. 消息补偿是最后一块拼图
&lt;/h2&gt;&lt;p&gt;为了避免重连期间丢消息，我给每条服务端消息增加了递增序列号，同时客户端保存最近一次成功消费的序号。重连后，客户端会把这个序号带给服务端，请求补发缺失区间。&lt;/p&gt;
&lt;p&gt;如果服务端无法补全，比如缓存窗口已经过期，我会触发一次全量刷新。虽然体验比增量恢复差，但至少不会让界面默默停在错误数据上。&lt;/p&gt;
&lt;p&gt;这个思路和消息队列里的 offset 很像。实时系统常常看起来是“推”的，真正稳定以后，背后一定要有某种“拉回来校验”的能力。&lt;/p&gt;
&lt;h2 id="6-我最后沉淀下来的工程原则"&gt;&lt;a href="#6-%e6%88%91%e6%9c%80%e5%90%8e%e6%b2%89%e6%b7%80%e4%b8%8b%e6%9d%a5%e7%9a%84%e5%b7%a5%e7%a8%8b%e5%8e%9f%e5%88%99" class="header-anchor"&gt;&lt;/a&gt;6. 我最后沉淀下来的工程原则
&lt;/h2&gt;&lt;p&gt;这次改完之后，我给自己记了几条很朴素但实用的原则：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;不要把 &lt;code&gt;onopen&lt;/code&gt; 当成连接成功的终点，它只是开始。&lt;/li&gt;
&lt;li&gt;不要把 &lt;code&gt;readyState === OPEN&lt;/code&gt; 当作健康状态的充分条件。&lt;/li&gt;
&lt;li&gt;重连必须带退避和抖动，否则故障期间会放大系统压力。&lt;/li&gt;
&lt;li&gt;心跳只能证明“最近还能说话”，不能证明“数据一定完整”。&lt;/li&gt;
&lt;li&gt;真正重要的是恢复路径是否可验证，而不是表面上连没连上。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;现在回头看，这个功能并不复杂，难的是一开始没有从“坏掉以后怎么办”的角度去设计。很多实时系统的可靠性，其实就是把这些边缘情况一条条补成默认能力。&lt;/p&gt;</description></item></channel></rss>