| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839 |
- chat_room 故障复盘、学习过程与修复说明
- 说明:
- 这份文档不是只写“最后修好了什么”,而是按学习过程来写,尽量把这次排查中遇到的每一个关键问题都讲清楚:
- 1. 现象是什么
- 2. 为什么会出现这种现象
- 3. 我是怎么一步一步定位的
- 4. 每一步排查时学到了什么
- 5. 代码层面具体改了什么
- 6. 部署层面具体改了什么
- 7. 最后怎么验证问题真的被解决了
- 一、问题背景
- 这个项目是一个基于 WebSocket 的简易聊天室,分为两部分:
- 1. 服务端
- 路径:D:\Network\chat_room\chat
- 技术:Java + Gradle + Java-WebSocket
- 作用:接收客户端连接,处理登录、广播、私聊、在线用户等逻辑。
- 2. 客户端
- 路径:D:\Network\chat_room\user
- 技术:Android Java
- 作用:手机端聊天界面,负责连接服务器、发送消息、接收消息、展示消息。
- 线上部署方式:
- 1. Java 聊天服务实际监听 8080。
- 2. Nginx 监听公网 80。
- 3. Nginx 把 WebSocket 请求反向代理到 127.0.0.1:8080。
- 4. 所以客户端默认连接地址是:
- ws://124.220.100.95
- 二、最开始用户看到的现象
- 当时用户看到的是:
- 1. App 可以打开。
- 2. 顶部状态一直显示“登录中”。
- 3. 输入框可以输入文字。
- 4. 发送按钮是灰色,点不动。
- 5. 两台手机之间不能像 QQ / 微信 那样正常互发消息。
- 6. 有时看起来像是“已经连上服务器了”,但消息并没有真正互通。
- 从用户角度看,这个问题很容易理解成:
- 1. 是不是按钮坏了
- 2. 是不是前端界面写错了
- 3. 是不是服务器地址没填对
- 4. 是不是端口没开
- 但真正的问题并不是单点故障,而是一个完整链路上的组合问题。
- 三、第一次判断时为什么容易误判
- 这次排查里,最容易误判的地方在于:
- 1. WebSocket 连接本身确实能建立。
- 2. Nginx 也确实在转发请求。
- 3. 所以看起来像是“网络是通的”。
- 为什么这会误导人?
- 因为很多人会把下面两件事混为一谈:
- 1. TCP / WebSocket 连接成功
- 2. 业务登录成功
- 实际上这是两层不同的事情:
- 1. WebSocket 连接成功,只说明底层链路通了。
- 2. 业务登录成功,说明服务端已经正确处理了 LOGIN,并把登录确认回给客户端。
- 这次问题里,第一层是通的,第二层是不通的。
- 这也是这次学习过程中最重要的一点之一:
- 排查实时通信问题时,不能只看“连上没连上”,一定要继续问:
- 1. 连接建立后,第一条业务消息有没有发出去
- 2. 服务端有没有收到
- 3. 服务端有没有返回确认
- 4. 客户端有没有收到确认
- 5. 客户端有没有根据确认更新自己的状态
- 四、发送按钮为什么会是灰色
- 这个问题必须先从客户端逻辑理解。
- 客户端新版本的发送按钮不是单纯由界面样式控制,而是由业务状态控制。
- 按钮可点的条件是:
- 1. WebSocket 已连接
- 2. 登录已完成
- 也就是类似:
- 1. isConnected == true
- 2. isLoggedIn == true
- 只要第二个条件不成立,按钮就会保持禁用状态。
- 所以发送按钮灰色,并不一定说明按钮组件坏了,而更可能说明:
- 1. 客户端认为自己还没完成登录
- 2. 客户端在等待服务端的登录确认
- 这就是为什么我后来没有去直接“强行点亮按钮”,因为那样只是把表面效果改了,真正的业务状态依然是错的。
- 学习点:
- 前端控件状态如果由业务状态驱动,就应该顺着业务状态去找问题,而不是直接改样式。
- 五、真正卡住的位置到底在哪里
- 问题真正卡住的位置是:
- 客户端发送 LOGIN 之后,没有正确收到服务端的登录确认回包。
- 正常链路应该是:
- 1. 客户端建立 WebSocket 连接
- 2. onOpen 触发
- 3. 客户端自动发送 LOGIN
- 4. 服务端处理 LOGIN
- 5. 服务端返回:
- - Welcome
- - joined the chat
- - ONLINE
- 6. 客户端收到这些消息后,把自己状态改为“已登录”
- 7. 发送按钮解锁
- 但当时实际发生的是:
- 1. 客户端建立了 WebSocket 连接
- 2. 客户端确实发出了 LOGIN
- 3. 服务端也确实收到了 LOGIN
- 4. 但服务端没有把欢迎消息和在线列表正确返回给客户端
- 5. 客户端一直停在“登录中”
- 6. 发送按钮一直保持灰色
- 所以,故障真正卡住的位置不是“按钮组件”,而是“服务端登录确认链路”。
- 六、我是怎么一步一步定位这个问题的
- 这一部分很重要,因为这是完整的学习过程。
- 第一步:看本地代码结构
- 我先检查了本地仓库,确认项目到底有哪些部分。
- 当时先看到的是:
- 1. D:\Network\chat_room\chat
- 通过代码搜索,我确认了这部分是服务端,核心信息包括:
- 1. 使用 Java-WebSocket
- 2. 默认监听 8080
- 3. 有 LOGIN / ALL / PRIVATE / ONLINE 等消息类型
- 这一阶段学到的是:
- 先不要急着猜问题,要先搞清楚项目结构,知道“谁是服务端、谁是客户端、协议长什么样、消息怎么流转”。
- 第二步:确认真正客户端在哪里
- 一开始用户口头说客户端是 usera,但目录里实际上没有 usera。
- 我继续检查 D:\Network\chat_room,发现真实目录是:
- 1. D:\Network\chat_room\chat
- 2. D:\Network\chat_room\user
- 后面确认 user 才是真正的 Android 客户端工程。
- 这一阶段学到的是:
- 用户口述路径不一定和实际路径完全一致,排查时应该以实际文件系统为准。
- 第三步:检查客户端逻辑
- 我看了客户端的 MainActivity,重点关注:
- 1. 什么时候连接服务器
- 2. 什么时候发送 LOGIN
- 3. 发送按钮什么时候启用
- 4. 收到什么消息后才算登录成功
- 由此确认:
- 1. 客户端会自动连接服务器
- 2. onOpen 后会自动发送 LOGIN
- 3. 发送按钮依赖登录状态,不是单纯依赖连接状态
- 这一步的结论非常关键:
- 按钮灰色不是 UI 渲染错误,而是客户端有意识地禁止发送。
- 第四步:检查服务端本地逻辑
- 我继续读服务端代码,重点检查:
- 1. ChatApplication.java
- 2. ChatWebSocketServer.java
- 3. ChatMessageRouter.java
- 4. ChatRoomManager.java
- 通过这些文件,我确认了服务端登录流程大致是:
- 1. 收到 LOGIN
- 2. register 用户
- 3. 给当前用户发送 Welcome
- 4. 向所有人广播 joined
- 5. 给当前用户发送 ONLINE
- 到这里,逻辑表面上看是对的。
- 但“表面上看是对的”不等于“实现细节就是对的”。
- 第五步:本地启动服务端做联通测试
- 我在本地启动了服务端,并用脚本模拟两个 WebSocket 客户端。
- 这里做了几轮测试:
- 1. 先测单用户 LOGIN
- 2. 再测双用户 LOGIN
- 3. 再测 PRIVATE
- 4. 再测 ALL
- 这一步发现了一个重要现象:
- 1. 群聊和私聊主链路大体是能走通的
- 2. 但是登录阶段会出现异常状态消息
- 当时我就开始怀疑:
- 服务端的“已注册会话”和“当前处理中的 sender 对象”可能不是同一个状态。
- 第六步:直接连公网地址做协议测试
- 为了确认问题到底在本地代码、部署、还是线上环境,我没有只看本地,而是直接连公网:
- 1. 连接 ws://124.220.100.95
- 2. 手动发送 LOGIN JSON
- 3. 观察有没有回 Welcome / ONLINE
- 测试结果是:
- 1. WebSocket 能建立连接
- 2. 但服务端没有回任何登录确认消息
- 然后我又做了第二轮公网测试:
- 1. 两个客户端同时连接
- 2. 两边都发 LOGIN
- 3. 再由一边发 ALL
- 结果是:
- 1. 登录没有回包
- 2. 群聊也没有回包
- 这一步的意义是:
- 把问题从“客户端界面问题”彻底缩小成“线上服务端业务处理或部署版本问题”。
- 七、服务端代码里真正的 bug 是什么
- 问题文件是:
- chat\src\main\java\com\campus\chat\server\ChatMessageRouter.java
- 问题函数是:
- handleLogin()
- 原来的问题逻辑:
- 1. 收到 LOGIN 时,当前 sender 其实是一个临时会话对象:
- SessionInfo(null, conn)
- 2. 代码调用 roomManager.register(username, sender.getConnection())
- 3. register 会新建一个真正的已登录 SessionInfo(username, conn),并放进 sessionsBySocket / sessionsByUsername
- 4. 但是后续代码继续用旧的 sender 发 Welcome 和 ONLINE
- 5. 旧 sender 里的 username 仍然是 null
- 为什么这会出问题?
- 因为从 register 之后开始,真正应该代表当前用户的对象已经不是旧的 sender,而是 ChatRoomManager 里新注册进去的那个 SessionInfo。
- 如果继续拿旧 sender 发消息,就会出现:
- 1. 登录状态对象不一致
- 2. 某些地方认为已经登录
- 3. 某些地方仍然认为未登录
- 4. 最终客户端收不到完整稳定的登录确认
- 这一点本质上是“状态源不一致”的问题。
- 学习点:
- 注册用户、绑定连接、会话对象管理这类逻辑里,最怕的不是 if 写错,而是“系统里同时存在两个代表当前用户的对象”。
- 八、服务端 bug 是怎么修的
- 修复方式非常明确:
- 1. 先 register(username, sender.getConnection())
- 2. 再从 roomManager.findBySocket(sender.getConnection()) 取回真正已注册的 SessionInfo
- 3. 用这个 registeredSender 发 Welcome
- 4. 用这个 registeredSender 发 ONLINE
- 也就是说:
- 1. register
- 2. refetch session
- 3. use registered session for all post-login actions
- 修完后:
- 1. 登录后的消息发送使用的是统一状态对象
- 2. 服务端能正确回 Welcome
- 3. 服务端能正确回 ONLINE
- 4. 客户端能真正完成登录
- 九、为什么“不能发消息”和“对方手机收不到消息”会一起出现
- 这两个现象的根因其实是一条线上的。
- 原因链条是:
- 1. 客户端没完成登录
- 2. 发送按钮不解锁
- 3. 即使绕过按钮,有些消息也会因为服务端认为 sender 未登录而处理失败
- 4. 广播逻辑依赖 sender 已正确注册
- 5. 所以最终表现为“不能发、不能收、对方看不到”
- 学习点:
- 在即时通信系统里,“登录是否成功”不是独立的小问题,而是整个消息链路的前置条件。
- 十、客户端本身有哪些设计问题
- 除了服务端 bug,这次还顺手暴露出客户端原来设计上的一些问题。
- 问题 1:聊天主界面像调试面板
- 现象:
- 1. 服务器地址输入框直接放在聊天页
- 2. 昵称输入框也直接放在聊天页
- 3. 界面像“开发测试工具”,不像“普通聊天软件”
- 为什么这是问题?
- 因为:
- 1. 普通用户不应该每次进入聊天页都看到服务器地址
- 2. 昵称这种配置项更适合首次设置或设置页
- 3. 主聊天页应该尽量只保留会话相关元素
- 解决:
- 1. 去掉服务器地址输入框
- 2. 去掉昵称输入框
- 3. 服务器地址改内置
- 4. 昵称改成首次弹窗设置
- 5. 设置按钮用来修改昵称
- 问题 2:默认昵称固定为 guest
- 现象:
- 1. 两台设备可能都叫 guest
- 2. 第二台会被服务端判定为重名
- 3. 用户会误以为“好像已经连上了,只是发不出去”
- 为什么会这样?
- 1. 默认值写死,没考虑多设备测试
- 2. 用户又不一定第一时间主动改昵称
- 解决:
- 1. 默认昵称改成根据设备信息自动生成
- 2. 首次进入弹窗要求确认昵称
- 3. 昵称保存到本地
- 4. 遇到 Username already in use. 时强制重新设置
- 问题 3:消息重复显示风险
- 现象:
- 1. 客户端发送时本地先插一条自己的消息
- 2. 服务端广播回来又显示一条
- 为什么会这样?
- 因为客户端做了 optimistic UI,而服务端也会回广播。
- 解决:
- 1. 改成主要依赖服务端真实广播回包来展示消息
- 这样做的好处:
- 1. 自己发出去的消息和别人收到的消息展示逻辑一致
- 2. 更接近真实的最终状态
- 十一、客户端代码修改过程中遇到的问题
- 这一部分是纯学习过程,记录的是“在写和改代码过程中遇到的技术问题”。
- 问题 1:原文件中文文案乱码,导致精确补丁容易失败
- 现象:
- 1. MainActivity.java 里有些中文字符串显示成乱码
- 2. 按原字符串做上下文匹配时补丁不稳定
- 原因:
- 1. 文件原有编码和内容已经存在混乱
- 解决:
- 1. 放弃小块 patch
- 2. 改成整文件替换 MainActivity、activity_main.xml、strings.xml
- 学习点:
- 当文件上下文已经不可靠时,整文件替换有时比硬做小补丁更稳。
- 问题 2:旧客户端构建报告里有 JSONException 编译错误
- 现象:
- 1. 构建报告里曾有未处理 JSONException 的编译错误
- 原因:
- 1. 旧版消息解析流程里异常处理不完整
- 解决:
- 1. 重写 MainActivity 的消息处理逻辑
- 2. 重新编译 assembleDebug
- 问题 3:illegal forward reference
- 现象:
- 1. 重写 MainActivity 后,编译器提示 illegal forward reference
- 原因:
- 1. Runnable 字段在初始化时直接依赖了还没完全建立好的成员字段
- 解决:
- 1. 把 reconnectRunnable 改成 this::runReconnect
- 2. 真正逻辑放进 runReconnect() 方法中
- 学习点:
- Java 字段初始化顺序是非常具体的,复杂字段逻辑最好延后到方法里。
- 问题 4:匿名回调里 client 变量“可能尚未初始化”
- 现象:
- 1. 编译器提示 client 可能尚未初始化
- 原因:
- 1. 匿名内部类中引用了正在构造过程中的 client 本身
- 解决:
- 1. 使用 final ChatWebSocketClient[] clientRef = new ChatWebSocketClient[1]
- 2. 构造完成后再赋值 clientRef[0] = client
- 学习点:
- 这类问题不是业务错误,而是 Java 语言层面的闭包/初始化限制。
- 问题 5:修完客户端后,按钮还是灰的
- 现象:
- 1. 新客户端装上后,按钮一度还是灰的
- 为什么会这样?
- 1. 客户端逻辑已经修了
- 2. 但手机实际连接的还是线上旧服务端
- 3. 旧服务端仍然不给 Welcome / ONLINE
- 解决:
- 1. 不继续在按钮逻辑上瞎改
- 2. 继续追服务器部署状态
- 学习点:
- 本地代码改对了,不代表线上就已经生效。一定要把“代码状态”和“部署状态”分开看。
- 十二、部署排查过程中遇到的问题
- 这一部分是部署学习过程。
- 问题 1:最开始容易怀疑云防火墙没放 8080
- 现象:
- 1. 服务端默认监听 8080
- 2. 云控制台截图里没有明确放开 8080
- 为什么容易误判?
- 因为很多人看到“端口不一致”就会先怀疑防火墙。
- 怎么解决?
- 1. 登录服务器查看 nginx -T
- 2. 查看 ss -ltnp
- 3. 确认真实链路是:
- 80 -> nginx -> 127.0.0.1:8080
- 结果:
- 1. 证明不是必须公网直接放 8080
- 2. 这不是主因
- 问题 2:需要确认线上到底跑的是哪个版本
- 现象:
- 1. 本地代码修了
- 2. 但公网行为没变
- 为什么会这样?
- 因为本地代码和线上部署不是同一个概念。
- 怎么解决?
- 1. 通过 SSH 登录服务器
- 2. 查看 /etc/systemd/system/chat.service
- 3. 查看 /opt/chat 目录
- 4. 查看 Java 启动命令
- 结果:
- 1. 确认线上 chat.service 启动的是 /opt/chat/bin/chat
- 2. 确认线上真正运行的 jar 在 /opt/chat/lib/chat-0.0.1-SNAPSHOT.jar
- 问题 3:如何确认服务端“收到消息但没回消息”
- 现象:
- 1. 客户端拿不到回包
- 为什么不能只靠客户端猜?
- 因为必须区分:
- 1. 消息有没有发到服务器
- 2. 服务器有没有收到
- 3. 服务器收到后有没有处理
- 4. 处理后有没有发送回包
- 怎么解决?
- 1. 查 systemctl status chat --no-pager
- 2. 看日志里的 Raw message received
- 结果:
- 1. 确认线上 Java 进程确实收到了 LOGIN 和 ALL
- 2. 问题不是“消息没到服务器”,而是“服务器收到后没正确回包”
- 问题 4:scp 上传部署包卡住
- 现象:
- 1. 直接 scp 上传时超时
- 为什么会这样?
- 1. scp 在等待交互输入密码
- 2. 自动化环境没法手工输入
- 怎么解决?
- 1. 改用 Python 的 paramiko + SFTP
- 2. 在脚本里显式提供用户名和密码
- 结果:
- 1. 部署包能稳定上传
- 问题 5:服务器本机没有 websockets Python 库
- 现象:
- 1. 想在服务器本机做 Python WebSocket 回环测试
- 2. 报错 ModuleNotFoundError: No module named 'websockets'
- 为什么会这样?
- 1. 系统 Python 里没装这个依赖
- 怎么解决?
- 1. 没有为了诊断去污染服务器环境安装新库
- 2. 改用已有证据链:
- - 公网直连测试
- - systemd 日志
- - nginx 配置
- - Java 进程状态
- 结果:
- 1. 即使没有这条回环测试,也足够完成定位
- 学习点:
- 排查时不一定每个辅助工具都必须装齐,关键是证据链能闭环。
- 问题 6:线上跑的是旧版本 /opt/chat
- 现象:
- 1. 本地修好了
- 2. 客户端修好了
- 3. 公网测试还是旧行为
- 为什么会这样?
- 1. 服务器上 /opt/chat 还没替换成新构建
- 2. chat.service 仍然在跑旧 jar
- 怎么解决?
- 1. 本地重新构建 chat
- 2. 打包部署目录
- 3. 上传服务器
- 4. 停掉 chat.service
- 5. 备份旧的 /opt/chat 为 /opt/chat.bak
- 6. 解压新包到 /opt/chat
- 7. chown 给 chat:chat
- 8. 启动 chat.service
- 结果:
- 1. 线上服务真正切换到修复版
- 十三、线上部署实际执行了哪些事情
- 这是整个部署修复的完整过程。
- 1. 在本地执行:
- gradlew clean installDist test
- 目的:
- 1. 重新编译服务端
- 2. 生成新的运行目录
- 3. 跑服务端测试,确认本地构建没问题
- 2. 在本地把 build/install/chat 打成部署 tar 包
- 目的:
- 1. 把可运行目录整体封装,便于上传
- 3. 用 paramiko SFTP 上传到服务器
- 目的:
- 1. 避开 scp 交互密码问题
- 4. 在服务器上停止 chat.service
- 目的:
- 1. 避免覆盖运行中的目录
- 5. 备份旧目录
- 动作:
- 1. /opt/chat -> /opt/chat.bak
- 目的:
- 1. 保留旧版本,必要时可回滚
- 6. 把新 tar 包解压到 /opt/chat
- 目的:
- 1. 替换旧部署目录
- 7. 修正目录属主为 chat:chat
- 目的:
- 1. 保证 systemd 用 chat 用户运行时对目录有权限
- 8. 启动 chat.service
- 目的:
- 1. 让线上开始跑修复后的版本
- 9. 查看 systemctl status chat
- 目的:
- 1. 确认服务已经真正启动成功
- 十四、最终是怎么验证真的修好了
- 这一步非常重要,因为“感觉修好了”和“证明确实修好了”是两回事。
- 验证 1:公网登录验证
- 1. 直接连 ws://124.220.100.95
- 2. 手动发 LOGIN
- 3. 成功收到:
- - Welcome, verify_user.
- - verify_user joined the chat.
- - ONLINE
- 说明:
- 1. 登录确认链路已经恢复
- 验证 2:双端消息验证
- 1. 建两个公网 WebSocket 客户端
- 2. 分别登录 alice_fix 和 bob_fix
- 3. alice_fix 发 hello fixed
- 4. alice_fix 自己收到广播
- 5. bob_fix 也收到广播
- 说明:
- 1. 群聊广播链路恢复
- 2. 对方手机应该也能看到消息
- 验证 3:客户端行为验证
- 修好之后,客户端预期行为应该是:
- 1. 顶部状态从“登录中”切换为“在线”
- 2. 发送按钮解锁
- 3. 输入消息后可发送
- 4. 对方客户端能看到消息
- 十五、这次学习过程中最重要的收获
- 1. 连接成功不等于业务成功。
- WebSocket 建立成功,只说明链路通;是否能聊天,要看 LOGIN、ONLINE、ALL 这些业务消息是否真正完成闭环。
- 2. UI 状态很多时候只是业务状态的表象。
- 按钮灰色,并不一定是按钮自身的问题,往往是后面的业务状态没有变。
- 3. 状态管理最怕“对象不一致”。
- 这次服务端 bug 的本质不是语法错误,而是 register 之后继续使用旧 SessionInfo,导致状态源错位。
- 4. 本地修复不等于线上生效。
- 本地代码、构建产物、服务器部署目录、systemd 服务,都是不同层。每一层都可能是问题点。
- 5. 实时通信故障要按链路排查。
- 最稳的方法不是猜,而是逐段验证:
- - 客户端有没有发
- - 服务端有没有收
- - 服务端有没有回
- - 客户端有没有处理回包
- 6. 部署问题和代码问题常常同时存在。
- 这次不是“只修代码就完事”,也不是“只改服务器就完事”,而是两边都要改,最后再重新部署。
- 十六、最终结果
- 到本次修复结束时,主链路已经恢复:
- 1. 客户端可以连接公网地址 ws://124.220.100.95
- 2. 客户端可以收到 Welcome / ONLINE
- 3. 顶部状态会从“登录中”切换为“在线”
- 4. 发送按钮会解锁
- 5. 一台设备发消息,另一台设备可以收到
- 6. 线上服务已替换为新版本
- 7. 旧版本保留在 /opt/chat.bak 作为回滚备份
- 这说明:
- 1. 不是只修了界面
- 2. 不是只修了本地代码
- 3. 而是把“客户端逻辑 + 服务端逻辑 + 线上部署”这一整条链路修通了
|