This repository has been archived on 2026-11-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
DHEnry 9dfc96cd67 feat: markdown chat, nested quotes and read receipts
Move the web/server app under server/, add Markdown composer and rendering, cursor-insert image/file uploads, nested quote viewer, group read receipt dialog, and link Markdown attachments to sent messages.
2026-10-25 19:14:34 +08:00

20 KiB
Raw Permalink Blame History

Node 聊天网站 · 技术策划(MySQL + 本地存储版)

定位:公开注册的多用户聊天社区,支持 1对1 私聊、群聊、公开聊天室,支持图片与文件。 存储:MySQL 8.0(本机已装)+ 本地磁盘(无对象存储、无 Redis、无 Docker)。

0. 环境探测结果

项 结果
Node v24.21.0
npm 11.19.0(需用 npm.cmd,PowerShell 执行策略拦截了 npm.ps1)
MySQL C:\Program Files\MySQL\MySQL Server 8.0\mysqld.exe,8.0.46,服务 MySQL80 当前 Stopped
Docker 未安装(因此不用容器编排)
工作目录 D:\project\Chat(空目录)

开工第一步:以管理员身份执行 net start MySQL80,然后建库 chat。


1. 功能范围

MVP(可上线的最小闭环)

  • 账号:邮箱注册 + 验证、登录、找回密码、改昵称/头像、注销
  • 会话:私聊、群聊(建群/邀请/退群/踢人/设管理员)、公开聊天室(发现页/加入/退出)
  • 消息:文本 + 表情、图片、文件、历史游标分页、未读数、已读回执、2 分钟内撤回
  • 实时:Socket.IO 推送、在线状态、正在输入、断线重连补拉
  • 安全:限流、敏感词过滤、举报、拉黑、管理后台(封禁/删消息/删群)

V2

消息搜索(MySQL FULLTEXT + ngram)、@提及、浏览器通知、群公告、消息置顶、数据导出

V3

语音/视频(WebRTC)、端到端加密、机器人、第三方登录


2. 技术选型(最小依赖)

层 方案 说明
运行时 Node 24 原生 TS 剥离 + --watch
后端 Fastify 5 比 Express 快,Schema 校验内置
实时 Socket.IO 4 单进程内建 adapter,无需 Redis
数据库 MySQL 8.0.46(InnoDB / utf8mb4) 本机服务,Prisma 官方支持
ORM Prisma 6 迁移 + 类型安全 + prisma studio
文件 本地磁盘 storage/ + @fastify/multipart 无 MinIO/S3
图片处理 sharp 缩略图、转 webp、剥离 EXIF
限流 @fastify/rate-limit 内存桶 单实例足够
在线状态 进程内 Map 单实例足够,掉线落库 lastSeenAt
前端 Vite + React 19 + TS + Tailwind 4
数据层 Zustand + TanStack Query
校验 Zod(前后端共享)
日志 pino 结构化日志,正文不入日志
测试 Vitest + Supertest
运行守护 PM2(或 Windows 服务 NSSM) 不加 Docker

明确放弃的东西:Redis(在线态/限流/锁全部进程内或 MySQL 行锁)、MinIO/S3(走本地磁盘)、Docker(直接跑 MySQL 服务 + Node 进程)。


3. 架构

Browser (React SPA)
   │ HTTPS / WSS
   ▼
Nginx(可选,生产用;开发直接访问 3000)
   │
   ▼
Fastify 单进程
 ├── REST   /api/*
 ├── Socket.IO /socket.io
 ├── 静态   /assets、/uploads(鉴权后读盘)
 └── 内存态:限流桶、在线连接、输入中状态、会话写锁
   │
   ├── MySQL 8.0 (localhost:3306/chat)
   └── 本地磁盘 D:\project\Chat\storage\
        ├─ uploads/2026/10/<ulid>.jpg
        └─ thumbs/2026/10/<ulid>.webp

单实例约束(必须接受的取舍)

  • Socket.IO 内存 adapter 只能在一个 Node 进程内广播 → 不能用 cluster、不能多开 PM2 实例。
  • 在线状态、限流计数在内存,重启即丢(lastSeenAt 落库,可接受)。
  • 会话写串行用 MySQL 行锁,天然支持多实例,所以未来要横向扩时,只需给 Socket.IO 加 Redis adapter。
  • 容量预估:单进程可稳载 1000~2000 长连接、3000 msg/min 转发,对社区站足够。

4. 数据模型(Prisma + MySQL)

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")   // mysql://root:pass@localhost:3306/chat
}

model User {
  id              String    @id @db.Char(26)   // ULID
  username        String    @db.VarChar(32)    // 展示名
  handle          String    @unique @db.VarChar(24)  // 唯一标识,小写
  email           String    @unique @db.VarChar(190) // utf8mb4 下索引长度安全
  passwordHash    String    @db.VarChar(255)   // Argon2id
  avatarKey       String?   @db.VarChar(255)
  bio             String?   @db.VarChar(200)
  role            Role      @default(USER)
  status          Status    @default(ACTIVE)
  emailVerifiedAt DateTime? @db.DateTime(3)
  dmPolicy        DmPolicy  @default(EVERYONE)
  lastSeenAt      DateTime? @db.DateTime(3)
  createdAt       DateTime  @default(now()) @db.DateTime(3)
  @@index([status])
}

model Session {
  id        String    @id @db.Char(26)
  userId    String    @db.Char(26)
  tokenHash String    @unique @db.Char(64)
  userAgent String?   @db.VarChar(255)
  ip        String?   @db.VarChar(45)
  expiresAt DateTime  @db.DateTime(3)
  revokedAt DateTime? @db.DateTime(3)
  @@index([userId])
  @@index([expiresAt])
}

model Conversation {
  id            String   @id @db.Char(26)
  type          ConvType                      // DM | GROUP | PUBLIC
  dmKey         String?  @unique @db.VarChar(53) // minId:maxId,防重复建私聊
  name          String?  @db.VarChar(64)
  description   String?  @db.VarChar(255)
  avatarKey     String?  @db.VarChar(255)
  ownerId       String?  @db.Char(26)
  isListed      Boolean  @default(true)
  lastMessageAt DateTime @default(now()) @db.DateTime(3)
  createdAt     DateTime @default(now()) @db.DateTime(3)
  @@index([type, isListed, lastMessageAt])
}

model Member {
  id                String     @id @db.Char(26)
  conversationId    String     @db.Char(26)
  userId            String     @db.Char(26)
  role              MemberRole @default(MEMBER)
  lastReadMessageId String?    @db.Char(26)
  mutedUntil        DateTime?  @db.DateTime(3)
  joinedAt          DateTime   @default(now()) @db.DateTime(3)
  @@unique([conversationId, userId])
  @@index([userId, conversationId])
}

model Message {
  id             String    @id @db.Char(26)     // ULID,时间有序
  conversationId String    @db.Char(26)
  senderId       String    @db.Char(26)
  type           MsgType                        // TEXT | IMAGE | FILE | SYSTEM
  content        String?   @db.Text
  clientId       String?   @db.Char(26)
  replyToId      String?   @db.Char(26)
  createdAt      DateTime  @default(now()) @db.DateTime(3)
  deletedAt      DateTime? @db.DateTime(3)
  @@unique([senderId, clientId])                // 幂等
  @@index([conversationId, id])                 // 游标分页(可反向扫)
}

model Attachment {
  id         String @id @db.Char(26)
  messageId  String @db.Char(26)
  storageKey String @db.VarChar(255)
  thumbKey   String? @db.VarChar(255)
  fileName   String @db.VarChar(255)
  mimeType   String @db.VarChar(100)
  size       Int
  width      Int?
  height     Int?
  @@index([messageId])
}

model Report { id, reporterId, targetType, targetId, reason, status, handledBy, createdAt }
model Block  { blockerId, blockedId, createdAt  @@id([blockerId, blockedId]) }
model AuditLog { id, actorId, action, targetId, detail Json?, createdAt }

MySQL 落地要点

事项 做法
字符集 库/表统一 utf8mb4 + utf8mb4_0900_ai_ci;连接串加 ?charset=utf8mb4
ULID 列 CHAR(26) CHARACTER SET ascii COLLATE ascii_bin,避免大小写不敏感导致排序/唯一性异常
时间精度 全部 DATETIME(3),否则毫秒丢失会导致同秒消息排序抖动
邮箱大小写 入库前 toLowerCase();唯一索引本身不区分大小写,正好符合预期
排序分页 ULID 字典序 = 时间序,WHERE conversationId=? AND id<? ORDER BY id DESC LIMIT 30,走 (conversationId, id) 索引
写串行 BEGIN → SELECT id FROM Conversation WHERE id=? FOR UPDATE → INSERT Message → COMMIT,行锁保证同会话有序
全文检索(V2) FULLTEXT(content) WITH PARSER ngram,中文必须用 ngram 解析器
连接池 Prisma 默认 connection_limit=10,单进程够用;MySQL max_connections 默认 151
迁移 prisma migrate dev 开发 / prisma migrate deploy 上线

5. 接口设计

REST

POST   /api/auth/register          邮箱注册
POST   /api/auth/login             登录(access 15m + refresh 30d)
POST   /api/auth/refresh           refresh 轮换
POST   /api/auth/logout
POST   /api/auth/verify-email
POST   /api/auth/forgot-password
GET    /api/me
PATCH  /api/me

GET    /api/conversations          我的会话(按 lastMessageAt 倒序)
POST   /api/conversations/dm       创建/复用私聊(dmKey 幂等)
POST   /api/conversations/group    建群
GET    /api/rooms                  公开聊天室发现页
POST   /api/conversations/:id/join
DELETE /api/conversations/:id/leave
PATCH  /api/conversations/:id      改名/公告(管理员)
POST   /api/conversations/:id/members    邀请/踢人

GET    /api/conversations/:id/messages?before=<ulid>&limit=30
GET    /api/conversations/:id/messages?after=<ulid>      断线补拉
POST   /api/conversations/:id/read
POST   /api/messages/:id/recall

POST   /api/uploads                 multipart 直传本地磁盘(≤20MB)
GET    /api/files/:key              鉴权后读盘返回(支持 Range)
GET    /api/files/:key/thumb        缩略图

POST   /api/reports
POST   /api/blocks
GET    /api/admin/reports
POST   /api/admin/users/:id/ban

Socket.IO 事件

// C → S
chat:join        { conversationId }
chat:leave       { conversationId }
message:send     { conversationId, clientId, type, content, attachmentIds?, replyToId? }
message:read     { conversationId, lastMessageId }
typing:start     { conversationId }

// S → C
message:new      { message }
message:ack      { clientId, message }
message:read     { conversationId, userId, lastMessageId }
message:recalled { conversationId, messageId }
presence:update  { userId, online, lastSeenAt }
typing:update    { conversationId, userId, active }
conversation:updated { conversation }
error            { code, message }

设计约定:

  1. 幂等:前端生成 clientId(ULID),@@unique([senderId, clientId]) 兜底,重发不重复入库。
  2. 顺序:MySQL 行锁串行化同会话写入;时间取服务端 Date.now();前端按 id 排序。
  3. 补拉:重连后按本地最大 id 调 ?after=,超 200 条提示跳转未读。
  4. 多端:连接额外加入 user:{userId} 房间,同步已读/撤回。
  5. 鉴权:握手校验 access token,失败 401 → 前端刷新后重连。
  6. 在线态:Map<userId, Set<socketId>>,最后一个连接断开时写 lastSeenAt 并广播。

6. 本地文件存储设计

storage/
├─ uploads/2026/10/01H...X.jpg     原图
├─ thumbs/2026/10/01H...X.webp     512px 缩略图
└─ avatars/01H...X.webp            头像(256px)

上传链路:

1. 前端 FormData POST /api/uploads(multipart)
2. @fastify/multipart 流式落盘到临时文件,超过 20MB 立即中断
3. file-type 嗅探真实 MIME(不信任 Content-Type / 后缀)
4. 白名单校验:jpeg/png/webp/gif/pdf/zip/docx/xlsx/txt
5. 图片 → sharp 转 webp + 生成 512 缩略图 + 剥离 EXIF(防 GPS 泄漏)
6. 移动到 uploads/yyyy/mm/,写 Attachment 记录,返回 attachmentId
7. 发消息带 attachmentIds,服务端校验归属后绑定

访问控制:

  • 一律私有,GET /api/files/:key 先查库确认该文件所属会话,再校验当前用户是成员,最后 sendFile。
  • 响应头:Content-Disposition: attachment(非图片)、X-Content-Type-Options: nosniff、图片加 Content-Security-Policy: default-src 'none'。
  • 文件名不参与路径拼接(只认 storageKey),防目录穿越。
  • 大小限制:单文件 20MB、图片 8000×8000、每人每日 200MB(内存计数器 + 每日落库)。

容量规划:1000 人 × 每天 5 张 2MB 图 ≈ 10GB/天,需在 V2 加清理策略(孤儿文件每日扫、90 天未访问归档)。


7. 安全与社区治理

项 措施
注册滥用 邮箱验证必填、IP + 邮箱双维度限流(内存桶)
登录爆破 失败 5 次锁 15 分钟(内存计数,重启重置可接受)
刷屏 令牌桶 20 条 / 10 秒 / 人;相同内容 5 秒去重
密码 Argon2id(@node-rs/argon2,比 bcrypt 快且更安全)
XSS 消息纯文本渲染,绝不 dangerouslySetInnerHTML;CSP 严格
CSRF Cookie SameSite=Lax + 双提交 Token;WS 校验 Origin
越权 每次查询校验 Member 关系;群管操作校验 role 层级
骚扰 拉黑后不可私聊、不可 @;举报 3 次进人工队列
内容审核 本地敏感词表 + 热更新,命中打码并告警
上传 MIME 嗅探、白名单、私有访问、禁止脚本类后缀
审计 管理操作写 AuditLog,保留 180 天

8. 目录结构

Chat/
├─ apps/
│  ├─ server/
│  │  ├─ src/
│  │  │  ├─ index.ts
│  │  │  ├─ env.ts                 # Zod 校验环境变量,缺失即退出
│  │  │  ├─ plugins/               # prisma, auth, socket, ratelimit, upload, errorHandler
│  │  │  ├─ routes/                # auth, me, conversations, messages, rooms, uploads, files, admin
│  │  │  ├─ services/              # auth, message, conversation, upload, moderation, presence
│  │  │  ├─ sockets/               # handlers + 房间工具
│  │  │  ├─ lib/                   # ulid, password, mailer, spam-filter, storage
│  │  │  └─ shared/                # zod schema + 公共类型(前端复用)
│  │  └─ prisma/schema.prisma
│  └─ web/
│     ├─ src/
│     │  ├─ pages/                 # Login, Register, Chat, Rooms, Settings, Admin
│     │  ├─ components/            # Sidebar, MessageList, MessageItem, Composer, Uploader, Avatar
│     │  ├─ stores/                # auth, chat, presence
│     │  └─ lib/                   # socket, api, upload
│     └─ index.html
├─ storage/                        # 运行时生成,加入 .gitignore
├─ .env / .env.example
└─ package.json                    # npm workspaces

9. 里程碑

阶段 交付内容 预估
M0 基础 启动 MySQL80 → 建库 chat → Prisma 建表;workspace 脚手架;/health;Vite 代理联通 0.5 天
M1 账号 注册/邮箱验证/登录/refresh/登出、密码重置、Argon2、限流、路由守卫、个人设置 1.5 天
M2 私聊 会话列表、建私聊(dmKey 幂等)、游标分页历史、Socket 收发、乐观更新 + ack、未读数 1.5 天
M3 群聊与公开室 建群/邀请/踢人/管理员、公开室发现页、加入退出、在线状态、正在输入 2 天
M4 文件 本地直传、MIME 嗅探、缩略图、鉴权读盘、配额与清理任务 1.5 天
M5 治理 举报/拉黑、敏感词、撤回、管理后台、审计日志 1.5 天
M6 上线 响应式 + 暗色主题、Nginx/PM2、压测(1000 连接)、备份脚本 1 天

合计约 9.5 天;M0–M3 结束即为可用的聊天社区。


10. 部署与运维(Windows 单机)

开发:

net start MySQL80                 # 管理员权限
npm run dev                       # server: tsx watch / web: vite

生产:

npm run build                     # 前端 dist + 后端 tsc
pm2 start apps/server/dist/index.js --name chat
pm2 save
  • 反向代理:Nginx for Windows 或 IIS + ARR(/api、/socket.io 需 Upgrade 头、proxy_read_timeout 300s)
  • 环境变量:DATABASE_URL、JWT_SECRET、PORT、STORAGE_ROOT、SMTP_*、PUBLIC_ORIGIN
  • 备份(每日计划任务):
    • mysqldump -u root -p chat > backup\chat_%date%.sql
    • robocopy storage backup\storage /MIR
  • 日志:pino 输出到 logs/app-YYYY-MM-DD.log,保留 14 天
  • 监控:/health 返回 DB 连通性;pm2 monit 看内存/CPU

11. 首日待确认项

  1. MySQL root 密码(或让我新建专用账号 chat_app,只授权 chat 库)
  2. 是否已有域名/服务器(决定 Nginx 还是先本地跑)
  3. 邮件服务(SMTP / 阿里云邮件推送;没有的话注册验证先用控制台打印 Token 调试)
  4. 是否需要第三方登录(GitHub / Google)

12. 当前实现状态(2026-10-24)

已完成并可在本地运行:

  • 账号:注册、Cap 人机验证、登录、登出、邮箱验证流程、找回密码、个人资料、会话管理。
  • 双重验证:支持 TOTP(身份验证器)和 Passkey/WebAuthn;登录密码通过后再进行第二步验证。
  • 邮件:开发环境 MAIL_MODE=console 输出验证/重置链接;生产环境可切换 MAIL_MODE=smtp 并通过 SMTP_* 配置发送。
  • 网盘:每个用户默认 1GB 配额;最多一层文件夹;聊天上传的图片和文件自动进入网盘;文件和文件夹均支持分享、可选提取密码、取消分享;支持按 7/30 天清理当前文件夹文件。
  • 网盘后台:管理员可查看用户用量、调整配额、查看/删除用户文件和分享。
  • 会话:私聊、群聊、公开聊天室、历史游标分页、撤回、举报、拉黑。
  • 好友:好友申请/接受/删除;私聊创建与私聊发送均强制要求好友关系,不受旧的 dmPolicy 影响。
  • 已读:按成员 lastReadMessageId 计算每条消息的已读/未读,支持“全部已读”和已读成员提示,并实时同步。
  • 备注:用户备注保存于 UserRemark,展示为 备注(原用户名);群/会话备注保存于 Member.remark。
  • 会话管理:置顶(Member.pinned / pinnedAt)、删除会话显示但保留历史(Member.hiddenAt,新消息自动恢复)。
  • 消息:支持引用回复;群聊多人正在输入提示用逗号并列显示;群主/群管理员/站点管理员可以撤回他人群消息,普通成员只能撤回自己 2 分钟内的消息;群管理员可置顶消息,每个群最多 3 条。
  • 消息转发与收藏:支持多选消息转发为“聊天记录”卡片,点击卡片以弹窗查看原始消息、图片和文件;支持收藏/取消收藏消息,并通过左上角收藏夹查看。
  • 群公告与置顶:群公告默认展开并可折叠;置顶消息默认收起,展开后双击置顶项可查看详情并直接收藏。
  • 好友:每个用户默认是自己的好友,可给自己发消息(类似文件传输助手)。
  • 群成员邀请:邀请人只能邀请自己的好友,且双方不能存在拉黑关系。
  • 组织与标签:管理员可编辑用户组织和标签;组织以黄色 @组织名 显示在用户面板、成员列表、通讯录和聊天用户名旁。
  • 管理后台:举报处理、用户状态管理、管理员直接注册用户、用户组织/标签、群组搜索/编辑/删除、消息管理(搜索/查看/删除),列表默认每页 10 条并支持加载更多。
  • 账号状态:禁言(SUSPENDED)只禁止发言,仍可登录和阅读;封禁(BANNED)才会强制下线。
  • 文件:本地磁盘分年月存储、图片转 webp + 缩略图、MIME 嗅探、鉴权读取;上传写入前自动创建父目录。
  • 品牌:SITE_NAME、SITE_LOGO_URL 配置站点名称与 Logo;前端标题和登录页同步更新。
  • 主题:浅色/深色/跟随系统;响应式布局。

数据库迁移:20261024140000_user_meta_and_conversation_prefs(用户组织/标签、用户备注、成员备注、置顶、隐藏、已读位)。

启动:

npm.cmd run dev

或分别启动:

npm.cmd -w @chat/server run dev
npm.cmd -w @chat/web run dev

局域网访问:Vite 已监听 0.0.0.0,其他设备可用 http://<本机局域网IP>:5173 打开;如通过域名/局域网 IP 访问,把 .env 的 PUBLIC_ORIGIN 改成实际地址。