From 5e579200394fcc18570a7ed669bee6099675bfbb Mon Sep 17 00:00:00 2001 From: Seven <1530011482@qq.com> Date: Mon, 28 Sep 2026 11:07:06 +0800 Subject: [PATCH] =?UTF-8?q?ai=E7=94=9F=E6=88=90=E8=AE=BE=E8=AE=A1=E4=B9=A6?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=8C=E5=B9=B6=E8=A7=A3=E5=86=B3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=AD=E6=8C=87=E5=87=BA=E7=9A=84=E9=83=A8=E5=88=86?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- DESIGN.md | 208 +++++++++++++++++++++++++++++++++ db/sql.txt | 105 +---------------- routes/message.js | 5 +- websocket/connectionManager.js | 10 +- websocket/index.js | 53 ++++----- 5 files changed, 248 insertions(+), 133 deletions(-) create mode 100644 DESIGN.md diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..a19427e --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,208 @@ +# Chat Demo Server 设计说明 + +## 1. 文档范围 + +本文依据当前仓库中的代码和 SQL 脚本,记录聊天服务端现有结构、模块职责、接口和消息协议、数据模型及设计取舍。文中“当前行为”指代码已经实现的行为;代码中存在但可能导致运行失败或安全风险的部分会在“已知问题与建议”中单独说明,不将计划性改进描述为已有能力。 + +## 2. 项目概述 + +本项目是一个基于 Node.js 的单进程聊天服务端,提供 HTTP REST 接口和 WebSocket 实时连接。Express 和 WebSocket 共用同一个 HTTP Server,监听 3000 端口;MySQL 保存用户、联系人和聊天消息。 + +当前实现的主要能力: + +- 用户注册、用户名密码登录,并在登录成功后签发 JWT。 +- 使用 JWT 中间件保护用户信息、联系人和聊天记录查询接口。 +- 查询联系人、每个联系人的最近一条消息,以及指定联系人的分页消息。 +- WebSocket 连接通过 JWT 验证身份,维护进程内在线用户连接表,并广播在线列表。 +- WebSocket 消息处理代码包含消息发送、投递和 ACK 转发逻辑。 + +## 3. 技术栈与依赖 + +| 技术/依赖 | 用途 | +| --- | --- | +| Node.js | JavaScript 服务端运行环境 | +| Express 5 | HTTP 服务、JSON 请求体解析和路由分发 | +| `http` | 创建与 Express 共用的 HTTP Server | +| `ws` | WebSocket 服务端实现 | +| `mysql2` | MySQL 单连接及参数化 SQL 查询 | +| `jsonwebtoken` | JWT 签发和验证 | +| `dotenv` | 将 `.env` 配置加载到 `process.env` | +| `nodemon` | 开发时监视文件并重启服务 | + +项目使用 CommonJS 模块格式。`npm start` 执行 `node index.js`,`npm run dev` 执行 `nodemon index.js`。当前 `npm test` 是占位脚本,没有自动化测试。 + +## 4. 代码结构 + +```text +index.js 服务启动入口、HTTP 与 WebSocket 组装 +db/ + mysql.js MySQL 连接创建与导出 + sql.txt users、contacts、messages 的建表及样例数据脚本 +middleware/ + authMiddleware.js HTTP 请求 JWT 校验 +routes/ + login.js 注册和登录接口 + user.js 用户信息与联系人接口 + message.js 最近聊天、历史消息、REST 消息写入接口 +utils/ + jwt.js JWT 签发和验证封装 +websocket/ + index.js WebSocket 握手、协议分发和消息处理 + connectionManager.js 进程内在线连接管理及广播 +``` + +### 模块职责与依赖 + +- `index.js`:加载环境变量,创建 Express 应用和 HTTP Server,启用 JSON 请求解析,注册 `/api` 路由并将同一个 Server 交给 WebSocket 初始化函数,最后监听 3000 端口。 +- `db/mysql.js`:创建一个 `mysql2` 单连接,并作为共享模块导出,路由和 WebSocket 处理器直接调用 `db.query`。 +- `routes/login.js`:处理公开的 `/login`、`/register`。登录成功后通过 JWT 工具生成 token。 +- `routes/user.js`:依赖 `authMiddleware`,从 `req.user.username` 识别当前用户并提供用户信息与联系人查询。 +- `routes/message.js`:历史记录及最近聊天查询依赖认证中间件;当前 REST `/sendMessage` 未挂载认证中间件。 +- `middleware/authMiddleware.js`:从 `Authorization` 请求头拆出 token,调用 JWT 验证,将 payload 放入 `req.user` 后继续处理请求。 +- `utils/jwt.js`:使用 `JWT_SECRET` 签发和验证 token,签发有效期为 10 分钟。 +- `websocket/index.js`:从连接 URL 查询参数读取 token 验证客户端身份;连接建立或关闭时广播在线用户列表;按消息 `type` 分发 WebSocket 消息。 +- `websocket/connectionManager.js`:使用模块级 `Map` 以用户名为键保存 WebSocket 连接。该状态仅存在于当前 Node 进程内。 +- `db/sql.txt`:描述用户、联系人、消息表,以及本地演示用数据。 + +## 5. 总体架构与请求路径 + +```mermaid +flowchart LR + Client[客户端] + Entry[index.js / HTTP Server] + Express[Express 路由] + Auth[JWT 中间件与工具] + WS[WebSocket 服务] + Manager[在线连接 Map] + DB[(MySQL)] + + Client -->|HTTP /api| Entry + Entry --> Express + Express --> Auth + Express --> DB + Client <-->|WebSocket| Entry + Entry --> WS + WS --> Auth + WS --> Manager + WS --> DB +``` + +HTTP 请求由 Express 根据 `/api` 前缀分发。需要认证的路由先运行 JWT 中间件,随后执行业务 SQL。WebSocket 则在握手后从 URL 查询参数读取 token,再在连接生命周期内以已验证的用户名处理消息。HTTP 与 WebSocket 最终都使用同一 MySQL 模块。 + +## 6. 数据模型 + +数据库脚本目标库为 `chat_mysql`,定义以下三张表: + +### `users` + +| 字段 | 类型 | 约束/用途 | +| --- | --- | --- | +| `username` | `VARCHAR(50)` | 主键,用户唯一标识 | +| `password` | `VARCHAR(255)` | 非空;当前代码保存并直接比较明文密码 | + +### `contacts` + +| 字段 | 类型 | 约束/用途 | +| --- | --- | --- | +| `user_name` | `VARCHAR(50)` | 联系人所属用户,外键引用 `users.username` | +| `contact_name` | `VARCHAR(50)` | 联系人用户名,外键引用 `users.username` | + +`(user_name, contact_name)` 为联合主键。同一用户不能重复添加同一联系人。脚本中的联系人关系按有向关系保存,双向关系需要分别插入两行。 + +### `messages` + +| 字段 | 类型 | 约束/用途 | +| --- | --- | --- | +| `id` | `INT` | 自增主键 | +| `sender_name` | `VARCHAR(50)` | 非空,外键引用发送者 | +| `receiver_name` | `VARCHAR(50)` | 非空,外键引用接收者 | +| `content` | `VARCHAR(1000)` | 非空,消息正文 | +| `created_at` | `DATETIME` | 默认使用数据库当前时间 | + +消息以一条记录表示一次单向发送。双方聊天记录通过发送者和接收者字段组合查询;表中没有当前 WebSocket 处理器所写入的 `message_id` 字段。 + +## 7. HTTP 接口设计 + +所有路由统一挂载在 `/api` 下,JSON 请求体由 Express 解析。成功/失败响应大多采用 `{ code, message, data }` 结构,但不同接口的 HTTP 状态码与业务 `code` 使用并不完全一致。 + +| 方法 | 路径 | 认证 | 输入/用途 | +| --- | --- | --- | --- | +| `POST` | `/api/register` | 否 | body:`username`、`password`;插入用户 | +| `POST` | `/api/login` | 否 | body:`username`、`password`;验证凭据并返回 token、用户名 | +| `GET` | `/api/userInfo` | 是 | 从 token 返回当前用户名 | +| `GET` | `/api/contactsInfo` | 是 | 查询当前用户联系人,返回 `contacts` 数组 | +| `GET` | `/api/recentChats` | 是 | 查询当前用户每个聊天对象的最新消息 | +| `POST` | `/api/contactMessagesInfo` | 是 | body:`contactName`、`limit`、`offset`;分页查询双方消息 | +| `POST` | `/api/sendMessage` | **否** | body:`senderName`、`receiverName`、`content`;将消息插入数据库 | + +### 认证约定 + +HTTP 客户端应在受保护接口的请求头中传入 `Authorization: Bearer `。登录接口响应的 token 字符串自身已带 `Bearer ` 前缀。当前中间件通过空格拆分请求头并取第二段作为 token;缺少请求头或验证失败时返回 401。 + +JWT payload 当前包含 `username`,过期时间为 10 分钟。WebSocket 客户端把 token 放在连接 URL 查询参数 `token` 中。由于登录响应包含 `Bearer ` 前缀,而 WebSocket 端会直接将查询参数交给 `verifyToken`,客户端需要传纯 JWT 部分,不能把 `Bearer ` 前缀一并作为 token 值。 + +## 8. WebSocket 协议与消息流程 + +WebSocket 挂载在 HTTP Server 上,客户端连接地址的形式为 `ws://:3000/?token=`。连接验证通过后,服务端按用户名登记连接,并广播在线列表。 + +### 服务端消息类型 + +| `type` | 方向 | 当前代码行为 | +| --- | --- | --- | +| `userOnline` | 服务端 -> 所有在线客户端 | 连接建立或关闭后,携带 `userList` 在线用户名数组 | +| `newMessage` | 服务端 -> 接收方 | 携带 `message` 对象,包括消息 ID、发送方、接收方和正文 | +| `sendSuccess` | 服务端 -> 发送方 | 携带客户端提供的 `messageId`,表示发送成功 | +| `sendError` | 服务端 -> 发送方 | 数据库写入回调报错时尝试发送,携带 `messageId` 和错误说明 | +| `ack` | 服务端 -> 原发送方 | 将接收方确认的消息 ID 转发给发送方 | + +### 客户端消息类型 + +- `sendMessage`:处理器读取 `messageId`、`receiverName`、`content`;发送者身份来自已验证 WebSocket 连接,不从消息体读取。 +- `ack`:处理器预期使用 `messageId` 和 `senderName` 将 ACK 转发给原发送者。 + +### 预期发送流程 + +1. 客户端建立携带 JWT 的 WebSocket 连接。 +2. 服务端校验 JWT,登记用户名与连接,并广播在线用户列表。 +3. 客户端发送 `sendMessage`,服务端使用连接身份确定发送者。 +4. 服务端尝试把消息持久化到 MySQL;接收方在线时向其推送 `newMessage`。 +5. 接收方返回 `ack` 后,服务端尝试通知原发送方。 + +步骤 4 当前实现没有等待数据库回调成功再确认和推送;此外建表脚本与插入语句字段不匹配,因此此流程目前不能视为可靠送达流程。具体差异见第 10 节。 + +## 9. 关键设计思路 + +- **按职责拆分模块**:入口负责组装,路由负责 HTTP 接口,middleware 负责认证,utils 封装 JWT,websocket 目录处理长连接和连接表,db 统一导出数据库连接。 +- **用户名作为用户标识**:用户表以用户名为主键,联系人关系、消息发送接收双方及在线连接表均使用用户名关联。 +- **JWT 无服务端会话存储**:服务端通过签名和过期时间验证 token,HTTP 请求和 WebSocket 握手各自执行验证。 +- **聊天列表与历史消息分开查询**:`recentChats` 使用窗口函数按聊天对象分组并选出最新消息;历史消息接口按 limit/offset 分页,避免单次返回全部记录。 +- **在线状态在内存维护**:当前使用 Map 直接由用户名定位 WebSocket 连接,便于单进程内向在线用户推送消息,但没有跨进程共享能力。 +- **SQL 参数化**:业务查询使用 `?` 占位符与参数数组传值,避免把用户输入直接拼入 SQL 文本。 + +## 10. 已知问题与后续建议 + +以下内容是基于当前代码与脚本的直接核对结果,应在扩展功能或对外部署前处理: + +1. **数据库凭据写在源码中**:`db/mysql.js` 将 MySQL 账号、密码和数据库名直接传给连接创建函数。建议改为读取环境变量,轮换已经放入源码的凭据,并避免将真实密钥写入文档或版本库。 +2. **密码以明文存储和比对**:注册直接写入密码,登录 SQL 按用户名与明文密码匹配。应使用成熟密码哈希方案存储和校验,并考虑统一认证失败响应。 +3. **REST 消息写入缺少认证**:`/api/sendMessage` 未使用 `authMiddleware`,客户端可自行指定发送者。应要求认证并从 `req.user` 派生发送者身份,同时校验接收方及联系人关系。(已解决) +4. **WebSocket 数据库写入字段不匹配**:`websocket/index.js` 插入 `message_id`,但 `db/sql.txt` 的 `messages` 表没有该列。需统一消息 ID 设计,例如新增唯一字段及迁移,或使用现有自增 `id` 并调整协议和查询。(已解决) +5. **发送确认早于持久化结果**:`sendSuccess` 和接收方推送发生在异步 `db.query` 回调之前。应将成功确认和实时推送放到成功回调中;失败时只返回错误,不推送为已发送消息。(已解决) +6. **连接管理器广播引用未定义的 `WebSocket`**:`connectionManager.js` 的 `broadcast` 检查 `WebSocket.OPEN`,但文件中没有导入 `ws`。当广播函数被调用时可能抛出 `ReferenceError`。应在此模块引入库常量,或由连接对象状态采用明确且可用的检查方式。(已解决) +7. **ACK 参数调用不一致**:消息分发通过 `handleAck(data)` 调用,而函数签名是 `handleAck(receiverName, data)`;函数内部随后读取第二个参数,可能因 `data` 未定义而失败。应统一函数参数并以当前连接用户名作为 ACK 接收者身份。 +8. **重复登录连接的关闭竞态**:同一用户名的新连接会覆盖 Map 中旧连接;旧连接关闭时无条件按用户名删除,可能把新连接也从 Map 移除。移除连接时应确认 Map 中仍是即将关闭的那个 WebSocket。 +9. **分页和输入校验不足**:`contactMessagesInfo` 未验证 `contactName`、`limit`、`offset` 的类型与范围,也未约束最大页大小。建议验证请求参数并设置默认值和上限。 +10. **在线状态及可靠性受单进程限制**:Map 不支持多实例共享、服务重启恢复、离线消息投递或持久化 ACK 状态。若部署多实例,需要共享在线状态/消息协调机制;若要求可靠消息,应定义消息状态、幂等键、重试及离线投递策略。 +11. **配置与错误响应尚未统一**:数据库连接配置没有从 `.env` 读取;HTTP 接口混用 HTTP 状态码和响应体 `code`,空结果有时返回业务码 401。建议集中配置并统一 API 错误语义及日志策略。 +12. **SQL 初始化脚本需要整理验证**:脚本包含清空用户表的语句,执行前会删除该表全部用户数据。应将演示数据与建表迁移分离,并在测试数据库验证脚本。 +13. **缺少自动化测试**:当前测试脚本是占位命令。建议优先为认证边界、参数校验、消息持久化失败/成功和 WebSocket 连接生命周期补充测试。 + +## 11. 本地运行与部署边界 + +1. 安装 Node.js 及 MySQL,并在 MySQL 中准备 `chat_mysql` 数据库。 +2. 按需执行经过校验的建表脚本;注意当前 `sql.txt` 含删除数据语句,不应直接用于已有数据环境。 +3. 设置 `JWT_SECRET`。当前 MySQL 参数仍需在 `db/mysql.js` 中配置;建议先将连接参数迁移到环境变量,再部署。 +4. 执行 `npm install`,开发环境使用 `npm run dev`,普通启动使用 `npm start`。 +5. 服务监听 `http://localhost:3000`,根路径 `/` 返回运行提示;WebSocket 使用相同主机和端口。 + +当前代码适用于本地演示和单进程验证,不具备生产环境所需的凭据管理、密码安全、输入校验、多实例在线状态和消息可靠性保障。 \ No newline at end of file diff --git a/db/sql.txt b/db/sql.txt index 62fddc6..52ebbae 100644 --- a/db/sql.txt +++ b/db/sql.txt @@ -11,7 +11,7 @@ VALUES ('query', '123'), ('tom', '123'), ('jack', '123'), -('lucy', '123'), +('lucy', '123'), ('lily', '123'); select * from chat_mysql.users; @@ -37,13 +37,14 @@ VALUES ('query', 'admin'), ('query', 'tom'), ('tom', 'admin'), -('tom', 'query'), +('tom', 'query'), ('admin', 'lily'), ('lily', 'admin'); select * from chat_mysql.contacts; CREATE TABLE chat_mysql.messages ( id INT PRIMARY KEY AUTO_INCREMENT, + message_id VARCHAR(64) NOT NULL, sender_name VARCHAR(50) NOT NULL, receiver_name VARCHAR(50) NOT NULL, content VARCHAR(1000) NOT NULL, @@ -55,103 +56,3 @@ CREATE TABLE chat_mysql.messages ( FOREIGN KEY (receiver_name) REFERENCES chat_mysql.users(username) ); -INSERT INTO chat_mysql.messages -(sender_name, receiver_name, content, created_at) -VALUES --- admin ↔ query -('admin', 'query', '你好,最近怎么样?', '2026-09-20 10:00:00'), -('query', 'admin', '挺好的,你呢?', '2026-09-20 10:01:00'), -('admin', 'query', '我也不错,最近在做聊天项目。', '2026-09-20 10:02:00'), -('query', 'admin', '听起来不错,是用 Vue 做的吗?', '2026-09-20 10:03:00'), -('admin', 'query', '对,前端使用 Vue2,后端使用 Node.js。', '2026-09-20 10:04:00'), -('query', 'admin', '那数据库使用 MySQL?', '2026-09-20 10:05:00'), -('admin', 'query', '对,目前正在完善聊天功能。', '2026-09-20 10:06:00'), - --- admin ↔ tom -('admin', 'tom', '你今天有空吗?', '2026-09-20 11:00:00'), -('tom', 'admin', '有啊,怎么了?', '2026-09-20 11:01:00'), -('admin', 'tom', '想和你讨论一下项目。', '2026-09-20 11:02:00'), -('tom', 'admin', '可以,什么时间方便?', '2026-09-20 11:03:00'), -('admin', 'tom', '下午三点怎么样?', '2026-09-20 11:04:00'), -('tom', 'admin', '没问题。', '2026-09-20 11:05:00'), - --- admin ↔ jack -('jack', 'admin', '周末一起吃饭吗?', '2026-09-20 12:00:00'), -('admin', 'jack', '可以啊,去哪吃?', '2026-09-20 12:01:00'), -('jack', 'admin', '附近找一家餐厅吧。', '2026-09-20 12:02:00'), -('admin', 'jack', '好的,晚上六点怎么样?', '2026-09-20 12:03:00'), -('jack', 'admin', '可以,到时候联系。', '2026-09-20 12:04:00'), - --- admin ↔ lucy -('admin', 'lucy', '最近项目进展怎么样?', '2026-09-20 13:00:00'), -('lucy', 'admin', '目前进展还不错。', '2026-09-20 13:01:00'), -('admin', 'lucy', '还有哪些功能没有完成?', '2026-09-20 13:02:00'), -('lucy', 'admin', '主要是消息通知和联系人功能。', '2026-09-20 13:03:00'), -('admin', 'lucy', '好的,我这边也在做消息功能。', '2026-09-20 13:04:00'), - --- query ↔ tom -('query', 'tom', '你知道聊天项目怎么做 WebSocket 吗?', '2026-09-20 14:00:00'), -('tom', 'query', '知道一些,可以用来做实时消息。', '2026-09-20 14:01:00'), -('query', 'tom', '我也准备把项目改成 WebSocket。', '2026-09-20 14:02:00'), -('tom', 'query', '这样聊天消息可以实时推送。', '2026-09-20 14:03:00'), - --- tom ↔ query -('tom', 'query', '联系人比较多的时候怎么办?', '2026-09-20 15:00:00'), -('query', 'tom', '不需要每个联系人都创建一个 WebSocket。', '2026-09-20 15:01:00'), -('tom', 'query', '一个用户维护一个 WebSocket 连接就可以了。', '2026-09-20 15:02:00'); -select * from chat_mysql.messages; - -INSERT INTO chat_mysql.messages -(sender_name, receiver_name, content, created_at) -VALUES --- admin ↔ lily -('admin', 'lily', '你好,最近怎么样?', '2026-09-20 10:00:00'), -('lily', 'admin', '挺好的,你呢?', '2026-09-20 10:01:00'), -('admin', 'lily', '我也不错,最近在做聊天项目。', '2026-09-20 10:02:00'), -('lily', 'admin', '听起来不错,是用 Vue 做的吗?', '2026-09-20 10:03:00'), -('admin', 'lily', '对,前端使用 Vue2,后端使用 Node.js。', '2026-09-20 10:04:00'), -('lily', 'admin', '那数据库使用 MySQL?', '2026-09-20 10:05:00'), -('admin', 'lily', '对,目前正在完善聊天功能。', '2026-09-20 10:06:00'), -('lily', 'admin', '聊天记录是怎么保存的?', '2026-09-20 10:07:00'), -('admin', 'lily', '目前是保存在 messages 表里面。', '2026-09-20 10:08:00'), -('lily', 'admin', '一个消息对应一条数据吗?', '2026-09-20 10:09:00'), -('admin', 'lily', '对,每条消息都有自己的 id。', '2026-09-20 10:10:00'), -('lily', 'admin', '那发送人和接收人怎么区分?', '2026-09-20 10:11:00'), -('admin', 'lily', '通过 sender_name 和 receiver_name 两个字段区分。', '2026-09-20 10:12:00'), -('lily', 'admin', '这样查询两个人的聊天记录应该比较方便。', '2026-09-20 10:13:00'), -('admin', 'lily', '对,只需要判断双方的用户名即可。', '2026-09-20 10:14:00'), -('lily', 'admin', '比如查询 admin 和 lily 之间的消息?', '2026-09-20 10:15:00'), -('admin', 'lily', '没错,可以使用 OR 和 AND 组合条件。', '2026-09-20 10:16:00'), -('lily', 'admin', '那如果消息很多怎么办?', '2026-09-20 10:17:00'), -('admin', 'lily', '不能一次把所有消息都返回给前端。', '2026-09-20 10:18:00'), -('lily', 'admin', '应该使用分页查询吧?', '2026-09-20 10:19:00'), -('admin', 'lily', '对,比如一次只查询 10 条或者 20 条。', '2026-09-20 10:20:00'), -('lily', 'admin', '这样可以减少接口返回的数据量。', '2026-09-20 10:21:00'), -('admin', 'lily', '也可以提高页面加载速度。', '2026-09-20 10:22:00'), -('lily', 'admin', '最近聊天列表也是单独查询吗?', '2026-09-20 10:23:00'), -('admin', 'lily', '对,最近聊天只需要获取每个联系人的最新消息。', '2026-09-20 10:24:00'), -('lily', 'admin', '这样左边就不用加载完整聊天记录了。', '2026-09-20 10:25:00'), -('admin', 'lily', '对,点击联系人之后再加载具体的聊天记录。', '2026-09-20 10:26:00'), -('lily', 'admin', '这个设计比较合理。', '2026-09-20 10:27:00'), -('admin', 'lily', '我现在就是准备按照这个思路修改。', '2026-09-20 10:28:00'), -('lily', 'admin', '那 Vue 里面是不是也不用保存所有消息?', '2026-09-20 10:29:00'), -('admin', 'lily', '是的,只需要保存当前联系人的消息数组。', '2026-09-20 10:30:00'), -('lily', 'admin', '分页的时候再把历史消息添加进去?', '2026-09-20 10:31:00'), -('admin', 'lily', '对,可以把下一页的数据添加到数组前面。', '2026-09-20 10:32:00'), -('lily', 'admin', '那新发送的消息就添加到数组最后面?', '2026-09-20 10:33:00'), -('admin', 'lily', '没错,因为新消息的时间是最新的。', '2026-09-20 10:34:00'), -('lily', 'admin', '左边的最近聊天还需要移动到最上面。', '2026-09-20 10:35:00'), -('admin', 'lily', '对,可以使用 unshift 把它移动到第一位。', '2026-09-20 10:36:00'), -('lily', 'admin', '同时更新左边显示的最后一条消息。', '2026-09-20 10:37:00'), -('admin', 'lily', '这样聊天列表就会实时更新了。', '2026-09-20 10:38:00'), -('lily', 'admin', '如果切换联系人,消息数组怎么办?', '2026-09-20 10:39:00'), -('admin', 'lily', '可以按照联系人 id 分别保存。', '2026-09-20 10:40:00'), -('lily', 'admin', '比如 contactMessages[18] 保存 lily 的消息?', '2026-09-20 10:41:00'), -('admin', 'lily', '对,contactMessages[20] 就可以保存另一个人的消息。', '2026-09-20 10:42:00'), -('lily', 'admin', 'Vue2 动态增加这个属性需要注意响应式问题吧?', '2026-09-20 10:43:00'), -('admin', 'lily', '对,可以使用 this.$set 来创建新的消息数组。', '2026-09-20 10:44:00'), -('lily', 'admin', '这样切换联系人就比较方便了。', '2026-09-20 10:45:00'), -('admin', 'lily', '对,聊天页面的数据结构基本就确定了。', '2026-09-20 10:46:00'), -('lily', 'admin', '那接下来就可以测试分页加载了。', '2026-09-20 10:47:00'), -('admin', 'lily', '嗯,先测试一次查询 10 条消息。', '2026-09-20 10:48:00'), -('lily', 'admin', '然后再查询下一页历史消息。', '2026-09-20 10:49:00'); diff --git a/routes/message.js b/routes/message.js index 7433195..4d8298f 100644 --- a/routes/message.js +++ b/routes/message.js @@ -128,7 +128,10 @@ router.post( ); // 写入聊天消息 -router.post("/sendMessage", (req, res) => { +router.post( + "/sendMessage", + authMiddleware, + (req, res) => { const message = req.body; const sql = ` INSERT INTO messages diff --git a/websocket/connectionManager.js b/websocket/connectionManager.js index 360a53a..056727b 100644 --- a/websocket/connectionManager.js +++ b/websocket/connectionManager.js @@ -1,3 +1,5 @@ +const WebSocket = require("ws"); + const onlineUsers = new Map(); // 保存用户连接 @@ -30,9 +32,13 @@ function getOnlineUsers() { function broadcast(data) { const message = JSON.stringify(data); - for (const ws of onlineUsers.values()) { + for (const [username, ws] of onlineUsers) { if (ws.readyState === WebSocket.OPEN) { - ws.send(message); + ws.send(message, (error) => { + if (error) { + console.error(`向用户 ${username} 广播失败:`, error); + } + }); } } } diff --git a/websocket/index.js b/websocket/index.js index 0a9e4ab..368a1dd 100644 --- a/websocket/index.js +++ b/websocket/index.js @@ -138,38 +138,35 @@ function handleSendMessage(senderName, data) { } return; } - }); + // 数据库确认写入后,才确认发送并推送给接收者 + const senderWs = getConnection(senderName); + if (senderWs) { + senderWs.send( + JSON.stringify({ + type: "sendSuccess", + messageId: messageId + }) + ); + } - // 告诉发送者:服务器已经保存成功 - const senderWs = getConnection(senderName); - if (senderWs) { - senderWs.send( + const receiverWs = getConnection(receiverName); + if (!receiverWs) { + console.log(`${receiverName} 当前不在线`); + return; + } + + receiverWs.send( JSON.stringify({ - type: "sendSuccess", - messageId: messageId + type: "newMessage", + message: { + messageId, + senderName, + receiverName, + content + }, }) ); - } - - // 找到接收者的WebSocket - const receiverWs = getConnection(receiverName); - if (!receiverWs) { - console.log(`${receiverName} 当前不在线`); - return; - } - - // 推送消息 - receiverWs.send( - JSON.stringify({ - type: "newMessage", - message: { - messageId, - senderName, - receiverName, - content - }, - }) - ); + }); } function handleAck(receiverName, data) {