📡 项目简介
NN 聊天室 是我在本机跑的一个小型多人即时通讯项目,UI 走的是玻璃拟态暗色风格,后端用 Flask + SocketIO 实时推送消息。前端是 jQuery + 原生 CSS,主要服务对象是我自己(有时会拉几个朋友进来测试)。
源码已开源:📦 ciallo0721-cmd/NN-Chat
名字里 NN 是取自「Neighborly Network」——其实没想那么多,就是两个字母凑着顺眼喵。
它从最早只能发文字,慢慢加上了:图片/文件上传、好友私聊、群组聊天、表情回应、消息撤回/编辑、未读红点、@ 提及、输入状态指示、最近联系人排序……反正该有的社交功能都堆了一遍。
🧩 技术栈
| 层 | 选型 | 备注 |
|---|---|---|
| 后端框架 | Flask | Python 3.13,单文件 1800+ 行的启动器 |
| 实时通信 | Flask-SocketIO + eventlet | WebSocket 广播新消息 |
| WSGI | Waitress | 实际是被 monkey_patch 后的 eventlet 驱动 |
| 数据库 | SQLite + WAL 模式 | 单文件 chat.db,项目目录绝对路径 |
| 前端 | jQuery 3.6 + Font Awesome | 玻璃拟态暗色 CSS 全部手写 |
| 认证 | SHA256 + 随机盐 | Flask session 维护登录态 |
| 加密 | 已关 | 原 E2E XOR 实现有 bug,2026-07-28 彻底关停 |
🕳️ 踩过的坑
故事从某天注册新账号说起——点了注册按钮之后,转了 10 分钟也没跳到聊天界面。下面是这一晚上陆续翻出来的 bug 集合:
按钮转圈,location.reload() 也调用了,但页面死活不回聊天界面。
点击发送后弹「上传失败」,看 network 才发现 Content-Type 全是 text/html。
关掉页面再回来,以前的聊天全是 X1QD / 5LyO5aSY 这种鬼东西,base64 也解不回来。
我注册的「普通账户」自己被代码升成管理员——因为代码里有个「COUNT==0 自动 admin」的逻辑。
🛠️ 修复时间线
下面按时间顺序列出今晚一个一个翻出来 + 修掉的过程。每一项都是踩坑后定位到根因、验证后再继续下一个。
-
元凶:
@app.after_request的force_text_html
这段代码把所有响应(包括 JSON)的Content-Type强制改成text/html。jQuery 没指定dataType时按响应 Content-Type 推断,被骗成字符串处理,所以data.status === "success"永远 false。注册按钮转圈成功但不 reload。 -
前端
$.ajaxSetup({ dataType: 'json' })
加这一行兜底,所有 ajax 默认按 JSON 解析,无论服务端返回什么 Content-Type。 -
force_text_html改成fix_content_type
只在响应 没有 Content-Type 时才补成 text/html,已经有 JSON/图片/文件的响应一律不动。 -
Session 在 LAN IP 下被浏览器悄悄拦截
我用http://192.168.18.66:5000访问,Chrome 似乎对私有 IP + SameSite=Lax 有特殊行为,session 写不进去。改用127.0.0.1访问没问题。但更稳的方案是再加 fallback cookie:注册时多写两个普通 cookienn_uid/nn_uname,/路由的认证改成 session → fallback cookie → DB 三层兜底。 -
退出登录永远退不出去
原因:fallback cookie 没在 logout 时清掉。修法:logout路由同时set_cookie('nn_uid', '', max_age=0)。 -
msg_obj.image_path = file_path or None错
后端把 file_path 的值赋给 image_path,导致文件消息被前端当作图片。HTML 里<img>加载失败,只显示 alt 文字「图片」。修法:直接用image_path字段,不复用 file_path。HTTP 和 WebSocket handler 两处都改。 -
request.get_json()在 Content-Type 错时抛 415
前端$.post(url, JSON.stringify(...))默认 Content-Type 是 form-urlencoded,不是 JSON,后端拿到的是 None 而且抛 UNSUPPORTED MEDIA TYPE。修法:16 处全部加silent=True。 -
escHtml函数藏在{% if logged_in %}分支里
未登录时整段 JS 不渲染,showToast 调用 escHtml 直接报 ReferenceError。修法:提到公共区域。 -
E2E 加密的 key 同步顺序 bug
switchToChat切聊天时先 本地 生成 key,然后才异步 POST 到服务器。reload 后用/get_key取到的还是旧 key,但本地新 key 用错密钥去解密旧消息 → 乱码。修法:先/get_key拿服务器已有 key,拿到后再fetchMessages。 -
最终:直接关掉 E2E
上面修完了但还是太麻烦——直接删掉所有 XOR 加解密,删 chat.db 和 sf/ 重新开始。消息存纯明文,reload 一万次都不乱码。
最终修复完,全链路 curl 测试一遍:注册 ✅、发文字 ✅、发图片 ✅、发文件 ✅、登录态保持 ✅、退出登录 ✅。重启服务器没报错。
9 个 bug 全部修复,全部明文存储,零加密。一晚上搞定 (๑•̀ㅂ•́)و✧
📸 现场截图
下面这 3 张图是这次修复过程中关键的现场记录:
图 1:两个客户端(test 和 admin)同时在浏览器里跑,左边收到的「牛逼牛逼果然可以回了」就是修复后能正常发送的消息。
图 2:我 ban 了左边的「12」——它一直都是普通用户。用 admin 账号 ban 普通用户,这才是正常的 ban 功能工作流程 (๑•̀ㅂ•́)و✧
图 3:被 ban 之后,登录页直接显示「您的账号已被封禁」红色提示,连输入框都没法用——这个机制工作得很好(虽然我是被自己 ban 的)。
🎭 彩蛋:把自己 ban 了
修复过程中顺手测试管理员后台的封号功能:注册了一个普通账号「12」,然后在后台 /admin/users 面板里 ban 掉了它。结果「12」开着浏览器没刷新,再操作时直接弹横幅「您的账号已被封禁」——封禁逻辑工作得挺好的(虽然对「12」本人不太友好)。
💭 总结感想
这次的 bug 串挺好看的——一个 force_text_html 引发的血案,带出来 Content-Type 推断、jQuery dataType、Session cookie 跨域、文件图片字段复用、HTTP 415、E2E 密钥同步…… 全是常见但绕的坑。
复盘之后最核心的教训是:不要复用语义不同的字段。image_path 和 file_path 一开始就分得清清楚楚就不会有这个 bug。同样地,session 状态也别只靠一个 cookie,至少给个 fallback。
还有就是「明文 vs 加密」的权衡——E2E 是个好东西,但做不出来就别做,不如老老实实存明文 + HTTPS 传输。修加密的 bug 比写明文代码多花几倍时间。
最后,IIS 占着 80 端口是今天另一个搞笑的发现:关掉 IIS 之后才能直接访问 http://localhost/,不然得加 :5000 端口号。停服务用 net stop W3SVC /yes,一招解决。