基于 PostgreSQL + Express + Socket.io 构建类 Discord 实时聊天应用:Chat App 全功能架构解析
2026/9/14 2:40:23 网站建设 项目流程

基于 PostgreSQL + Express + Socket.io 构建类 Discord 实时聊天应用:Chat App 全功能架构解析

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

本文以仓库中tools/llm-oneshot/apps/chat-app/typescript/opus-4-5/postgres/chat-app-20260104-180000/README.md为核心骨架,结合同目录下的完整源码(Drizzle ORM 数据模型、Express/Socket.io 后端、React/Vite 前端)进行纵深讲解。你将掌握:如何用 PostgreSQL 十六张表建模一个含私聊、定时消息、阅后即焚、线程、反应、实时权限的完整聊天系统;REST 与 Socket.io 双通道如何分工协作;以及定时任务如何驱动"定时消息投递、阅后即焚清理、输入指示过期、自动 Away"四大后台机制。

一、项目定位与功能总览

Chat App 是一个Discord 风格的实时聊天应用,技术选型为 PostgreSQL + Express + Socket.io + React。它不是一个只有"发消息"的玩具,而是覆盖了从基础聊天到实时协作的全功能集:

基础聊天(Basic Chat)

  • 用户以显示名(display name)注册
  • 创建/加入聊天房间(public 公开 / private 私密)
  • 实时消息收发
  • 在线用户状态展示

输入指示(Typing Indicators)

  • 实时显示"谁正在输入"
  • 5 秒无活动自动过期
  • 智能文案:"User is typing..." / "Multiple users are typing..."

已读回执(Read Receipts)

  • 追踪每条消息被谁看过
  • 消息下方展示 "Seen by X, Y, Z"
  • 用户查看消息时实时更新

未读计数(Unread Message Counts)

  • 房间列表上的徽标计数
  • 按用户×房间维度独立追踪
  • 实时更新

定时消息(Scheduled Messages)

  • 指定未来时间投递消息
  • 查看/取消待发送的定时消息
  • 到点自动出现在房间中

阅后即焚消息(Ephemeral/Disappearing Messages)

  • 提供 1 分钟 / 5 分钟自动删除选项
  • UI 显示倒计时
  • 过期后从数据库中永久删除

消息反应(Message Reactions)

  • 5 个 Emoji 反应:👍 ❤️ 😂 😮 😢
  • 可切换开/关自己的反应
  • 悬停查看谁点了反应

带历史的消息编辑(Message Editing with History)

  • 编辑自己发的消息
  • 显示 "(edited)" 标记
  • 点击可查看编辑历史

实时权限(Real-Time Permissions)

  • 房间创建者自动成为 admin
  • admin 可踢人/拉黑用户
  • admin 可提升其他用户为 admin
  • 权限变更即时生效

丰富用户状态(Rich User Presence)

  • 状态:online、away、dnd、invisible
  • 5 分钟无活动自动切为 away
  • 状态实时同步

消息线程(Message Threading)

  • 可对特定消息进行回复
  • 线程视图展示回复数量
  • 线程内容实时更新

私密房间与私信(Private Rooms & DMs)

  • 创建私密/邀请制房间
  • 按用户名邀请用户
  • 双人私信(DM)
  • 接受/拒绝邀请

二、技术栈与仓库结构

README 明确列出的技术栈如下:

层次技术选型
数据库PostgreSQL + Drizzle ORM
后端Express.js + Socket.io
前端React + Vite + TypeScript
认证JWT tokens

对应的仓库目录(以仓库根目录为基准)结构为:

tools/llm-oneshot/apps/chat-app/typescript/opus-4-5/postgres/chat-app-20260104-180000/ ├── client/ # React + Vite + TS 前端 │ ├── src/ │ │ ├── App.tsx # 主界面 │ │ ├── api.ts # REST 客户端封装 │ │ ├── socket.ts # Socket.io 客户端封装 │ │ ├── types.ts # 共享类型定义 │ │ └── main.tsx / styles.css │ ├── Dockerfile │ └── vite.config.ts ├── server/ # Express + Socket.io 后端 │ ├── src/ │ │ ├── index.ts # 全部 REST 端点 + Socket 事件 + 后台任务 │ │ ├── schema.ts # Drizzle ORM 表定义(10 张表 + 4 个枚举) │ │ └── db.ts # postgres-js 连接池 + drizzle 实例 │ ├── drizzle.config.ts # drizzle-kit 配置(db:push 用) │ └── package.json ├── docker-compose.yml # postgres + server + client 一键编排 └── GRADING_RESULTS.md # 功能评测结果(12 项功能评分与已知问题)

值得说明的是,这个应用目录位于仓库的tools/llm-oneshot评测框架下,GRADING_RESULTS.md 记录了该应用在 "Prompt Level 9" 下的评测结果(总分 23.0/36,63.9%),因此它既是一份可运行的参考实现,也是一份可供二次开发的评测样本。下文在分析各功能实现时会结合源码逐条印证。

三、快速开始:两种启动方式

3.1 Docker 一键启动(推荐)

在应用根目录(含docker-compose.yml)执行:

docker-compose up --build

打开 http://localhost:5174 即可访问。

docker-compose.yml(源码确认)编排了三个服务:

version: '3.8' services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: chat-app ports: - '5432:5432' volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U postgres'] interval: 5s timeout: 5s retries: 5 server: build: context: ./server dockerfile: Dockerfile environment: DATABASE_URL: postgres://postgres:postgres@postgres:5432/chat-app JWT_SECRET: chat-app-20260104-180000-secret PORT: 3001 ports: - '3001:3001' depends_on: postgres: condition: service_healthy client: build: context: ./client dockerfile: Dockerfile environment: VITE_API_URL: http://localhost:3001 ports: - '5174:5174' depends_on: - server

几个关键点:

  • 数据库就绪检查:server 通过depends_on: condition: service_healthy等待 PostgreSQL 通过pg_isready健康检查,避免启动竞态;
  • 环境变量贯穿DATABASE_URLJWT_SECRETPORT注入后端;VITE_API_URL注入前端(默认http://localhost:3001);
  • 端口约定:PostgreSQL 5432、后端 3001、前端 5174。

3.2 本地开发模式

  1. 先启动 PostgreSQL(端口 5432),可用 Docker 单独运行或使用本机实例;
  2. 启动服务端
cd server npm install npm run db:push # 用 drizzle-kit 将 schema.ts 推送到数据库 npm run dev # tsx watch 热重载
  1. 启动客户端
cd client npm install npm run dev # Vite dev server

打开 http://localhost:5174。

后端package.json(源码确认)中脚本定义如下:dev使用tsx watch src/index.ts实现热重载,buildtsc编译,start运行node dist/index.jsdb:push调用drizzle-kit push。连接字符串默认值为postgres://postgres:postgres@localhost:5432/chat-app,可通过环境变量DATABASE_URL覆盖(见 server/src/db.ts)。

四、数据模型:Drizzle ORM 十表设计

数据层定义全部集中在 server/src/schema.ts,共 10 张表、4 个 PostgreSQL 枚举。理解这套 schema 是理解全部功能的基础。

4.1 枚举定义

export const userStatusEnum = pgEnum('user_status', [ 'online', 'away', 'dnd', 'invisible', 'offline', ]); export const roomTypeEnum = pgEnum('room_type', ['public', 'private', 'dm']); export const memberRoleEnum = pgEnum('member_role', ['member', 'admin']); export const inviteStatusEnum = pgEnum('invite_status', [ 'pending', 'accepted', 'declined', ]);
  • user_status:用户状态机,REST 更新接口只接受online/away/dnd/invisibleoffline由服务端在断连时自动写入;
  • room_type:公开房间、私密房间、私信(DM 也复用 room 表);
  • member_role:普通成员与管理员;
  • invite_status:邀请生命周期。

4.2 用户与房间核心表

users(用户表)——主键为uuid随机生成,核心字段包括displayName(varchar(50),非空)、status(默认 online)、lastActiveAt(驱动自动 Away 判定)、lastMessageAt(驱动发消息频率限制)。对status建了索引users_status_idx,供"自动 Away 扫描"高效查询。

rooms(房间表)——serial自增主键,namevarchar(100),createdBy外键引用 users(删除时set null),roomType默认 public。对roomTypecreatedBy分别建索引。

room_members(房间成员表)——这是权限体系的核心:role(member/admin)、isBanned(拉黑标记)、lastReadAt(未读计数的水位线)。关键约束是unique('room_members_unique').on(table.roomId, table.userId),保证"一个用户在一个房间只有一条成员记录";同时为外键删除配置了onDelete: 'cascade'

4.3 消息及其衍生表

messages(消息表)——一张表承载四种消息形态:

  • 普通消息:content(varchar(2000))+parentId(为空则非回复);
  • 定时消息scheduledFor+isScheduled两个字段,并建了复合索引messages_scheduled_idx加速后台扫描;
  • 阅后即焚消息expiresAt字段,单独建索引messages_expires_idx
  • 线程parentId指向父消息,建messages_parent_idx

message_edits(编辑历史表)——保存previousContent(编辑前的原文)与editedAt,一次编辑写入一条记录,供"查看编辑历史"接口按desc(editedAt)倒序返回。

message_reactions(反应表)——emojivarchar(10),唯一约束(messageId, userId, emoji)保证同一用户对同一消息的同一 emoji 只能有一条记录,这正是"Toggle 开/关"得以实现的数据基础。

read_receipts(已读回执表)——唯一约束(messageId, userId)readAt记录阅读时间;标记已读时使用onConflictDoUpdate实现 UPSERT。

typing_indicators(输入指示表)——注释里明确说明"输入指示主要存内存,但保留表用于清理"。实际实现中每次typing:start会 UPSERT 一条带expiresAt的记录,由后台任务定期清理过期行并广播"停止输入"。

room_invites(邀请表)——invitedBy/invitedUser双外键,status默认 pending,唯一约束(roomId, invitedUser)防止重复邀请。

schema 文件末尾还通过$inferSelect/$inferInsert导出了UserRoomMessage等 TypeScript 类型,前端 client/src/types.ts 中UserRoomMessageReaction等接口与之逐字段对应。

五、REST API 端点全解

README 中列出的全部 API 在 server/src/index.ts 中均有对应实现。除注册外,所有端点都经由authenticateToken中间件校验Authorization: Bearer <token>

5.1 Auth 认证

方法路径说明
POST/api/auth/register注册新用户,返回{ user, token }
GET/api/auth/me获取当前用户
PUT/api/auth/displayName修改显示名
PUT/api/auth/status修改状态(online/away/dnd/invisible)

实现细节(源码确认):

  • 注册接口校验显示名长度 1~50 字符,通过jwt.sign({ userId: user.id }, JWT_SECRET)签发 token;
  • 修改显示名/状态成功后,会io.emit('user:updated', user)所有在线用户广播,实现"状态实时同步";
  • JWT 密钥默认chat-app-20260104-180000-secret,可由环境变量JWT_SECRET覆盖(生产环境务必更换)。

5.2 Rooms 房间

方法路径说明
GET/api/rooms列出公开房间
GET/api/rooms/my列出当前用户的房间(含私密房间)
POST/api/rooms创建房间(public/private)
POST/api/rooms/:id/join加入公开房间
POST/api/rooms/:id/leave离开房间
GET/api/rooms/:id/members获取房间成员
POST/api/rooms/:id/kick/:userId踢人(仅 admin)
POST/api/rooms/:id/ban/:userId拉黑(仅 admin)
POST/api/rooms/:id/promote/:userId提升为 admin(仅 admin)
POST/api/rooms/:id/invite邀请用户进入私密房间
GET/api/rooms/unread获取所有房间未读计数

实现要点(源码确认):

  • 创建房间时自动将创建者写入room_membersrole: 'admin',兑现"房间创建者即管理员";
  • 公开房间创建后io.emit('room:created', room)广播;私密房间不广播;
  • join 只允许公开房间,且校验isBanned(被拉黑返回 403);
  • kick/ban/promote 均先校验操作者role === 'admin';ban 采用"更新isBanned = true,若非成员则插入一条 banned 记录"的策略;kick/ban 除向房间广播room:member:kicked/banned外,还向被处理者个人频道user:{id}发送room:kicked/room:banned
  • /api/rooms/my通过room_members内连接rooms,过滤isBanned = false,返回时附带rolelastReadAt
  • 未读计数接口对每个成员房间执行count()聚合:统计createdAt > lastReadAt、非定时消息(isScheduled = false)、且非本人(ne(messages.userId, userId))的消息条数。

5.3 Messages 消息

方法路径说明
GET/api/rooms/:id/messages获取房间消息
POST/api/rooms/:id/messages发送消息(支持 parentId/scheduledFor/expiresInMinutes)
PUT/api/messages/:id编辑消息
GET/api/messages/:id/history获取编辑历史
GET/api/messages/:id/thread获取线程回复
POST/api/messages/:id/reactions切换反应
POST/api/messages/:id/read标记已读

实现要点(源码确认):

  • 发消息:校验内容 1~2000 字符;校验房间成员身份(未加入或被 ban 返回 403);内置500ms 速率限制(依据user.lastMessageAt,过快返回 429 "Sending messages too quickly");根据请求体计算expiresAt(阅后即焚)与scheduledFor/isScheduled(定时);
    • 若为普通消息io.to('room:{id}').emit('message:created', ...)实时广播;若带parentId还会统计回复数并广播message:thread:updated
    • 若为定时消息:不立即广播,等待后台任务到点投递;
  • 读取消息:只返回isScheduled = false的消息,或"定时消息但属于当前用户本人"(即作者自己可看到自己的定时草稿);
  • 编辑消息:仅作者本人可编辑;编辑前先把旧内容写入message_edits历史表,再更新正文并置isEdited = true,最后广播message:updated
  • 切换反应:先查(messageId, userId, emoji)记录,存在则删除(关闭),不存在则插入(开启),实现 Toggle;随后用groupBy(emoji)+array_agg(users.displayName)聚合出每个 emoji 的计数与点过的人,广播message:reactions:updated
  • 标记已读:UPSERT 进read_receipts,同时更新room_members.lastReadAt,然后广播message:read(携带读者显示名列表),前端据此渲染 "Seen by ..."。

5.4 DMs 与 Invites

方法路径说明
POST/api/dm创建/复用与指定用户的私信
GET/api/invites获取待处理的邀请
POST/api/invites/:id/accept接受邀请
POST/api/invites/:id/decline拒绝邀请

实现要点(源码确认):

  • 创建 DM:先通过room_type = 'dm'的房间 +array_agg(user_id)聚合查找是否已存在恰好包含双方的 DM,存在则直接返回已有房间;否则新建room_type: 'dm'房间并同时插入两条成员记录,向对方个人频道发送dm:created
  • 接受邀请:将 invite 置为accepted,若对方曾被 ban 则清除isBanned并以 member 角色重新加入,广播room:member:joined

六、Socket.io 实时事件体系

Socket.io 承担全部实时推送职责。连接建立时通过socket.handshake.auth.token完成 JWT 鉴权(server/src/index.ts),鉴权通过后:

  • 自动join('user:{userId}')个人频道,用于私信、踢人/拉黑、定时取消等定向推送;
  • 从数据库加载该用户全部成员关系,自动join('room:{roomId}')所有已加入的房间频道;
  • 将用户状态置为 online 并向全员广播user:updated;断连时置为 offline 并清理其输入指示记录。

6.1 客户端 → 服务端

事件说明
typing:start开始输入(带 roomId),UPSERT 输入指示记录
typing:stop停止输入,删除输入指示记录
activity用户活动心跳(驱动自动 Away)
room:join加入指定房间的 Socket 频道
room:leave离开指定房间的 Socket 频道

客户端 client/src/socket.ts 的实现细节:通过auth: { token }携带 JWT;用mousemove/keypress事件监听并每 60 秒兜底发送一次activity心跳;连接时读取VITE_API_URL(默认http://localhost:3001)。

6.2 服务端 → 客户端

事件触发场景
user:updated用户状态/资料变更(显示名、在线状态、自动 Away)
room:created新公开房间创建
room:member:joined/left/kicked/banned/promoted房间成员变更
room:invite收到新房间邀请(推送到个人频道)
dm:created新私信创建
message:created/updated/deleted消息新增/编辑/删除
message:reactions:updated反应变更(含计数与用户列表)
message:read已读回执更新(携带读者名单)
message:thread:updated线程回复数变化
typing:update输入指示变化(含自动过期清退)

一个值得注意的分工设计:REST 负责"写库 + 广播",Socket.io 负责"即时送达"。例如发消息接口在数据库插入成功后立即io.to('room:{id}').emit('message:created', ...),客户端无需轮询即可收到新消息;而GET /api/rooms/:id/messages则用于初次进入房间时的历史消息回填。

七、四大后台任务机制

server/src/index.ts 尾部通过四个setInterval实现了四个独立的后台机制,这是"定时消息、阅后即焚、输入指示过期、自动 Away"这四类时间驱动功能的服务端骨架:

1. 定时消息投递(每 5 秒):查询isScheduled = true AND scheduledFor <= now的消息,逐条置isScheduled = false并广播message:created,消息在预定时刻"出现"在房间中。

2. 阅后即焚清理(每 5 秒):查询expiresAt <= now且非空的消息,直接从数据库delete永久删除,并向房间广播message:deleted。README 中"Permanently deleted from database when expired"由此实现。

3. 输入指示清理(每 1 秒):查询expiresAt <= now的输入指示记录并删除,同时向房间广播isTyping: false——这就是"5 秒无活动自动消失"的后端保证。

4. 自动 Away(每 30 秒):以now - 5 分钟为阈值,将status = 'online'lastActiveAt早于阈值的用户批量置为away并广播user:updated

四个任务均配有 try/catch 与错误日志,属于典型的"轻量轮询 + 索引命中"方案,messages_scheduled_idxmessages_expires_idxtyping_indicators_expires_idxusers_status_idx正是为这四个扫描准备的查询索引。

八、评测结果与已知问题(二次开发清单)

应用目录下 GRADING_RESULTS.md 记录了 2026-01-04 的完整评测:后端 1,131 行、前端 2,222 行、共 20 个文件,编译与运行均通过,总分 23.0/36(63.9%),覆盖 Feature 1~12。

各功能得分概览:

功能得分功能得分
Basic Chat2.0/3Ephemeral Messages3/3
Typing Indicators3/3Message Reactions0/3
Read Receipts2.5/3Message Editing2.75/3
Unread Counts1.0/3Real-Time Permissions1.25/3
Scheduled Messages1.5/3Rich Presence2.5/3
Message Threading1.5/3
Private Rooms & DMs2.0/3

评测同时记录了 11 条关键问题,可直接作为二次开发的改进清单:

  1. 加入/离开房间需刷新才生效,且会显示重复房间名;
  2. 消息反应完全不可用(前后端接口均已实现,问题集中在 UI 联动);
  3. 已读回执 "Seen by" 名单顺序来回翻转(后端用array_agg聚合,顺序不稳定);
  4. 进入房间后未读徽标不清零(前端未在进入房间时主动调用已读标记);
  5. 定时消息响应慢、无法取消;
  6. 被踢/拉黑的用户未断开 WebSocket,仍能收到更新;
  7. 权限变更(提权)在 UI 中反映滞后;
  8. 状态变更未显示在成员侧边栏;
  9. 线程视图刷新后丢失、线程回复非实时。

其中第 6 条值得结合源码说明:服务端在 kick/ban 时虽然向被处理者个人频道发送了room:kicked/room:banned,但未真正执行socket.disconnect()或从room:{id}频道踢出,因此服务端仍会向该连接推送房间消息——修复方向可考虑在io.sockets.sockets中按socket.data.userId定位连接并强制socket.leave('room:{id}')或直接断开。

九、总结与扩展建议

从这份实现可以提炼出一套可复用的实时聊天应用架构模板

  • 持久化与实时分离:所有状态落 PostgreSQL(Drizzle ORM 管理 schema 与迁移),REST 做增删改查,Socket.io 做事件广播,两者以"写库后广播"的模式串联;
  • 一张消息表承载多种形态:通过parentId(线程)、scheduledFor/isScheduled(定时)、expiresAt(阅后即焚)三个可空字段,避免为每种消息形态建表;
  • 权限模型薄而有效room_members.role+isBanned+ 服务端中间件校验,即可支撑踢人/拉黑/提权/邀请;
  • 轮询式后台任务:四个setInterval搭配针对性索引,实现定时投递、过期清理、自动 Away 等时间驱动功能。

若要在该基础上继续演进,可优先处理第八章的 11 条已知问题(特别是反应功能与踢人断连),随后再考虑:将四个轮询任务迁移为 PostgreSQLpg_cron/消息队列以获得更高吞吐;为私密房间增加POST /api/rooms/:id/messages的成员级加密;以及在message_reactionsread_receipts等高频写入表上引入读写分离与缓存。

【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询