Archived
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.
20 KiB
20 KiB
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 }
设计约定:
- 幂等:前端生成
clientId(ULID),@@unique([senderId, clientId])兜底,重发不重复入库。 - 顺序:MySQL 行锁串行化同会话写入;时间取服务端
Date.now();前端按id排序。 - 补拉:重连后按本地最大
id调?after=,超 200 条提示跳转未读。 - 多端:连接额外加入
user:{userId}房间,同步已读/撤回。 - 鉴权:握手校验 access token,失败 401 → 前端刷新后重连。
- 在线态:
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%.sqlrobocopy storage backup\storage /MIR
- 日志:pino 输出到
logs/app-YYYY-MM-DD.log,保留 14 天 - 监控:
/health返回 DB 连通性;pm2 monit看内存/CPU
11. 首日待确认项
- MySQL root 密码(或让我新建专用账号
chat_app,只授权chat库) - 是否已有域名/服务器(决定 Nginx 还是先本地跑)
- 邮件服务(SMTP / 阿里云邮件推送;没有的话注册验证先用控制台打印 Token 调试)
- 是否需要第三方登录(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 改成实际地址。