14 KiB
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. 代码结构
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. 总体架构与请求路径
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>。登录接口响应的 token 字符串自身已带 Bearer 前缀。当前中间件通过空格拆分请求头并取第二段作为 token;缺少请求头或验证失败时返回 401。
JWT payload 当前包含 username,过期时间为 10 分钟。WebSocket 客户端把 token 放在连接 URL 查询参数 token 中。由于登录响应包含 Bearer 前缀,而 WebSocket 端会直接将查询参数交给 verifyToken,客户端需要传纯 JWT 部分,不能把 Bearer 前缀一并作为 token 值。
8. WebSocket 协议与消息流程
WebSocket 挂载在 HTTP Server 上,客户端连接地址的形式为 ws://<host>:3000/?token=<JWT>。连接验证通过后,服务端按用户名登记连接,并广播在线列表。
服务端消息类型
type |
方向 | 当前代码行为 |
|---|---|---|
userOnline |
服务端 -> 所有在线客户端 | 连接建立或关闭后,携带 userList 在线用户名数组 |
newMessage |
服务端 -> 接收方 | 携带 message 对象,包括消息 ID、发送方、接收方和正文 |
sendSuccess |
服务端 -> 发送方 | 携带客户端提供的 messageId,表示发送成功 |
sendError |
服务端 -> 发送方 | 数据库写入回调报错时尝试发送,携带 messageId 和错误说明 |
ack |
服务端 -> 原发送方 | 将接收方确认的消息 ID 转发给发送方 |
客户端消息类型
sendMessage:处理器读取messageId、receiverName、content;发送者身份来自已验证 WebSocket 连接,不从消息体读取。ack:处理器预期使用messageId和senderName将 ACK 转发给原发送者。
预期发送流程
- 客户端建立携带 JWT 的 WebSocket 连接。
- 服务端校验 JWT,登记用户名与连接,并广播在线用户列表。
- 客户端发送
sendMessage,服务端使用连接身份确定发送者。 - 服务端尝试把消息持久化到 MySQL;接收方在线时向其推送
newMessage。 - 接收方返回
ack后,服务端尝试通知原发送方。
步骤 4 当前实现没有等待数据库回调成功再确认和推送;此外建表脚本与插入语句字段不匹配,因此此流程目前不能视为可靠送达流程。具体差异见第 10 节。
9. 关键设计思路
- 按职责拆分模块:入口负责组装,路由负责 HTTP 接口,middleware 负责认证,utils 封装 JWT,websocket 目录处理长连接和连接表,db 统一导出数据库连接。
- 用户名作为用户标识:用户表以用户名为主键,联系人关系、消息发送接收双方及在线连接表均使用用户名关联。
- JWT 无服务端会话存储:服务端通过签名和过期时间验证 token,HTTP 请求和 WebSocket 握手各自执行验证。
- 聊天列表与历史消息分开查询:
recentChats使用窗口函数按聊天对象分组并选出最新消息;历史消息接口按 limit/offset 分页,避免单次返回全部记录。 - 在线状态在内存维护:当前使用 Map 直接由用户名定位 WebSocket 连接,便于单进程内向在线用户推送消息,但没有跨进程共享能力。
- SQL 参数化:业务查询使用
?占位符与参数数组传值,避免把用户输入直接拼入 SQL 文本。
10. 已知问题与后续建议
以下内容是基于当前代码与脚本的直接核对结果,应在扩展功能或对外部署前处理:
- 数据库凭据写在源码中:
db/mysql.js将 MySQL 账号、密码和数据库名直接传给连接创建函数。建议改为读取环境变量,轮换已经放入源码的凭据,并避免将真实密钥写入文档或版本库。 - 密码以明文存储和比对:注册直接写入密码,登录 SQL 按用户名与明文密码匹配。应使用成熟密码哈希方案存储和校验,并考虑统一认证失败响应。
- REST 消息写入缺少认证:
/api/sendMessage未使用authMiddleware,客户端可自行指定发送者。应要求认证并从req.user派生发送者身份,同时校验接收方及联系人关系。(已解决) - WebSocket 数据库写入字段不匹配:
websocket/index.js插入message_id,但db/sql.txt的messages表没有该列。需统一消息 ID 设计,例如新增唯一字段及迁移,或使用现有自增id并调整协议和查询。(已解决) - 发送确认早于持久化结果:
sendSuccess和接收方推送发生在异步db.query回调之前。应将成功确认和实时推送放到成功回调中;失败时只返回错误,不推送为已发送消息。(已解决) - 连接管理器广播引用未定义的
WebSocket:connectionManager.js的broadcast检查WebSocket.OPEN,但文件中没有导入ws。当广播函数被调用时可能抛出ReferenceError。应在此模块引入库常量,或由连接对象状态采用明确且可用的检查方式。(已解决) - ACK 参数调用不一致:消息分发通过
handleAck(data)调用,而函数签名是handleAck(receiverName, data);函数内部随后读取第二个参数,可能因data未定义而失败。应统一函数参数并以当前连接用户名作为 ACK 接收者身份。 - 重复登录连接的关闭竞态:同一用户名的新连接会覆盖 Map 中旧连接;旧连接关闭时无条件按用户名删除,可能把新连接也从 Map 移除。移除连接时应确认 Map 中仍是即将关闭的那个 WebSocket。
- 分页和输入校验不足:
contactMessagesInfo未验证contactName、limit、offset的类型与范围,也未约束最大页大小。建议验证请求参数并设置默认值和上限。 - 在线状态及可靠性受单进程限制:Map 不支持多实例共享、服务重启恢复、离线消息投递或持久化 ACK 状态。若部署多实例,需要共享在线状态/消息协调机制;若要求可靠消息,应定义消息状态、幂等键、重试及离线投递策略。
- 配置与错误响应尚未统一:数据库连接配置没有从
.env读取;HTTP 接口混用 HTTP 状态码和响应体code,空结果有时返回业务码 401。建议集中配置并统一 API 错误语义及日志策略。 - SQL 初始化脚本需要整理验证:脚本包含清空用户表的语句,执行前会删除该表全部用户数据。应将演示数据与建表迁移分离,并在测试数据库验证脚本。
- 缺少自动化测试:当前测试脚本是占位命令。建议优先为认证边界、参数校验、消息持久化失败/成功和 WebSocket 连接生命周期补充测试。
11. 本地运行与部署边界
- 安装 Node.js 及 MySQL,并在 MySQL 中准备
chat_mysql数据库。 - 按需执行经过校验的建表脚本;注意当前
sql.txt含删除数据语句,不应直接用于已有数据环境。 - 设置
JWT_SECRET。当前 MySQL 参数仍需在db/mysql.js中配置;建议先将连接参数迁移到环境变量,再部署。 - 执行
npm install,开发环境使用npm run dev,普通启动使用npm start。 - 服务监听
http://localhost:3000,根路径/返回运行提示;WebSocket 使用相同主机和端口。
当前代码适用于本地演示和单进程验证,不具备生产环境所需的凭据管理、密码安全、输入校验、多实例在线状态和消息可靠性保障。