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. 而是把“客户端逻辑 + 服务端逻辑 + 线上部署”这一整条链路修通了
