基于 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_URL、JWT_SECRET、PORT注入后端;VITE_API_URL注入前端(默认http://localhost:3001); - 端口约定:PostgreSQL 5432、后端 3001、前端 5174。
3.2 本地开发模式
- 先启动 PostgreSQL(端口 5432),可用 Docker 单独运行或使用本机实例;
- 启动服务端:
cd server npm install npm run db:push # 用 drizzle-kit 将 schema.ts 推送到数据库 npm run dev # tsx watch 热重载- 启动客户端:
cd client npm install npm run dev # Vite dev server打开 http://localhost:5174。
后端package.json(源码确认)中脚本定义如下:dev使用tsx watch src/index.ts实现热重载,build用tsc编译,start运行node dist/index.js,db: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/invisible,offline由服务端在断连时自动写入;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。对roomType和createdBy分别建索引。
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导出了User、Room、Message等 TypeScript 类型,前端 client/src/types.ts 中User、Room、Message、Reaction等接口与之逐字段对应。
五、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_members且role: '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,返回时附带role与lastReadAt;- 未读计数接口对每个成员房间执行
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_idx、messages_expires_idx、typing_indicators_expires_idx、users_status_idx正是为这四个扫描准备的查询索引。
八、评测结果与已知问题(二次开发清单)
应用目录下 GRADING_RESULTS.md 记录了 2026-01-04 的完整评测:后端 1,131 行、前端 2,222 行、共 20 个文件,编译与运行均通过,总分 23.0/36(63.9%),覆盖 Feature 1~12。
各功能得分概览:
| 功能 | 得分 | 功能 | 得分 |
|---|---|---|---|
| Basic Chat | 2.0/3 | Ephemeral Messages | 3/3 |
| Typing Indicators | 3/3 | Message Reactions | 0/3 |
| Read Receipts | 2.5/3 | Message Editing | 2.75/3 |
| Unread Counts | 1.0/3 | Real-Time Permissions | 1.25/3 |
| Scheduled Messages | 1.5/3 | Rich Presence | 2.5/3 |
| — | — | Message Threading | 1.5/3 |
| — | — | Private Rooms & DMs | 2.0/3 |
评测同时记录了 11 条关键问题,可直接作为二次开发的改进清单:
- 加入/离开房间需刷新才生效,且会显示重复房间名;
- 消息反应完全不可用(前后端接口均已实现,问题集中在 UI 联动);
- 已读回执 "Seen by" 名单顺序来回翻转(后端用
array_agg聚合,顺序不稳定); - 进入房间后未读徽标不清零(前端未在进入房间时主动调用已读标记);
- 定时消息响应慢、无法取消;
- 被踢/拉黑的用户未断开 WebSocket,仍能收到更新;
- 权限变更(提权)在 UI 中反映滞后;
- 状态变更未显示在成员侧边栏;
- 线程视图刷新后丢失、线程回复非实时。
其中第 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_reactions、read_receipts等高频写入表上引入读写分离与缓存。
【免费下载链接】SpacetimeDBDevelopment at the speed of light项目地址: https://gitcode.com/GitHub_Trending/sp/SpacetimeDB
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考