用 FastAPI + Vue3 手搓了一个"摸鱼"聊天网站:从灵感到上线(附赠体验地址)
用 FastAPI + Vue3 手搓了一个"摸鱼"聊天网站:从灵感到上线
在线体验:http://47.101.193.82/#/pond
摸鱼,是打工人刻在 DNA 里的浪漫。
于是我做了一个网站:把一句话丢进公共"鱼塘",等一个陌生人来"摸"走它,然后你们就聊上了。
项目代号 catchfish,线上名字叫 鱼鱼鱼 · 摸鱼聊天系统。
一、这是什么
一句话概括:一个以"放鱼 / 摸鱼 / 晒网"为核心玩法的陌生人随机聊天网站。
- 放鱼:把文字或表情丢进公共鱼塘(别人看不到你是谁,只看到内容)。
- 摸鱼:系统随机从鱼塘里捞一条"别人放的、你还没摸过"的鱼,丢进你的对话列表,你和放鱼人就此开启一段匿名聊天。
- 对话:按"鱼的来源用户"自动聚合,点进去可以"放鱼回应"。
- 晒网:朋友圈式的动态流,支持点赞、评论、删除自己的内容。
整个产品没有"关注/粉丝"那一套,也不需要填手机号、邮箱。你只需要记住一个暗号——它是你的账号,也是你的钥匙。
二、技术选型
| 层 | 技术 | 说明 |
|---|---|---|
| 后端 | Python 3.11 + FastAPI | 异步优先,自带 /docs 交互式接口文档 |
| ORM | SQLAlchemy 2.0(async) + aiomysql | 完全异步的数据库访问 |
| 数据库 | MySQL 8.0 | 单库 chat_fish |
| 前端 | Vue 3 + Vite + TypeScript + Pinia | 组合式 API,<script setup> 一把梭 |
| 实时 | WebSocket | 鱼塘概况、晒网更新、鱼被摸提醒的实时推送 |
| 鉴权 | JWT(HS256) | 无状态 token,前端 Pinia 持久化 |
| 部署 | Nginx + systemd + 云服务器 | FastAPI 单进程托管前端 dist,Nginx 反代 |
有意思的一点:后端 FastAPI 启动时会用 StaticFiles 直接挂载前端的 web/dist,前后端其实是同一个服务、同一个端口,部署时只跑一个进程即可。
# server/app/main.py —— 前端打包后直接由后端托管
if WEB_DIST.exists():
app.mount("/", StaticFiles(directory=str(WEB_DIST), html=True), name="web")
三、几个我觉得有意思的实现细节
1. "暗号"即账号:登录和注册是同一件事
传统流程要区分"注册"和"登录"两个按钮、两套校验。这里我把它们合并了:
输入一个新暗号 → 自动创建用户并分配随机昵称;
输入一个已有暗号 → 登录回原账号。
前端一个输入框,后端一个接口搞定:
# server/app/core/security.py
def code_tag(secret_code: str) -> str:
"""暗号指纹:换暗号后旧 token 立即失效"""
return hashlib.sha256((secret_code or "").encode()).hexdigest()[:12]
def create_access_token(user_id: int, tag: str | None = None, ...) -> str:
payload = {"sub": str(user_id), "exp": ..., "iat": ...}
if tag:
payload["tv"] = tag # token 里带上暗号指纹
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
这里有个小巧思:token 的 payload 里塞了一枚 tv(暗号指纹)。当你修改暗号时,旧 token 因为指纹对不上而立即失效——相当于"改密码让所有旧登录掉线",却不需要额外维护 token 黑名单。
2. 摸鱼算法:一条鱼同时只能被一个人摸着
"摸鱼"不是简单的 SELECT ... ORDER BY RAND() LIMIT 1。它有几条业务约束:
- 不能摸到自己放的鱼;
- 一条鱼同一时刻只能被一个人持有(别人摸走且还没"放生"的,别人不能再摸);
- 已经和以"未放生"状态聊着天的人,不再被摸到(避免反复刷到同一个陌生人);
- 摸鱼动作会自动开启你和放鱼人的单聊会话。
核心逻辑长这样:
# server/app/services/fish_service.py
async def catch_fish(db, user):
held_ids = await held_fish_stmt(db, user.user_id) # 我正摸在手上的
occupied = await occupied_fish_ids(db, user.user_id) # 被别人占着没放生的
chatted = await chat_service.chatted_peer_ids(db, user.user_id)
conds = [
FishPost.sender_id != user.user_id,
FishPost.is_available == 1,
FishPost.fish_id.not_in(held_ids),
]
if occupied: conds.append(FishPost.fish_id.not_in(occupied))
if chatted: conds.append(FishPost.sender_id.not_in(chatted))
candidates = (await db.execute(select(FishPost).where(*conds)
.order_by(FishPost.created_at.desc()).limit(200))).scalars().all()
if not candidates:
raise LookupError("鱼塘里没有新的鱼了,先放一条吧~")
fish = random.choice(candidates)
# 写摸鱼记录,并发冲突时回滚重试
db.add(FishCatch(user_id=user.user_id, fish_id=fish.fish_id, caught_at=now()))
try:
await db.commit()
except IntegrityError:
await db.rollback()
raise LookupError("这条鱼刚被别人摸走了,再摸一次试试~")
并发下"同一条鱼被两人同时摸到"的竞态,我用唯一约束 + IntegrityError 回滚兜底:事务提交失败就提示用户"鱼被别人抢走了",让用户再摸一次。简单可靠,不引入分布式锁。
3. WebSocket 实时推送:进程内的连接管理器
摸鱼现场需要"活"起来:有人放鱼,别人的"塘里的鱼"计数立刻 +1;有人点赞你的动态,你马上收到提醒。我用了一个进程内的 ConnectionManager,按 user_id 维护连接集合:
# server/app/ws/manager.py
class ConnectionManager:
def __init__(self):
self._conns: dict[int, set[WebSocket]] = {}
async def push(self, user_id: int, event: str, data=None):
conns = self._conns.get(int(user_id))
if not conns:
return
payload = json.dumps({"type": event, "data": data}, ensure_ascii=False, default=str)
for ws in list(conns):
try:
await ws.send_text(payload)
except Exception:
self.disconnect(user_id, ws) # 自动清理死连接
前端则把推送事件转成自定义事件 cf:pond / cf:chat-released,挂在 window 上,各页面通过 addEventListener 响应,做到"数据变了界面自动刷新":
// web/src/views/PondView.vue
window.addEventListener('cf:pond', (e) => {
stats.value = (e as CustomEvent).detail
})
⚠️ 设计取舍:因为是进程内管理器,多个 uvicorn worker 之间无法共享连接。所以部署时只用单 worker。将来要横向扩容,必须先引入 Redis Pub/Sub 做跨进程广播——这点在部署文档里明确标注了。
4. 游客模式:先玩后注册
打开网站不用登录就能放鱼、摸鱼。后端给每个游客分配一个临时 guest 身份,限制可放/可摸次数,次数用尽再引导"输入暗号开通账号"。这让新用户零门槛体验核心玩法,转化率友好。
四、目录结构一览
catchfish/
├── server/ # 后端 FastAPI
│ ├── app/
│ │ ├── main.py # 入口 / CORS / 托管前端 / 操作日志中间件
│ │ ├── core/ # config / security(JWT) / deps(鉴权)
│ │ ├── db/ # engine + 自动迁移(migrate.py,幂等)
│ │ ├── models/ # 用户 / 鱼帖 / 摸鱼记录 / 会话 / 动态 / 点赞 / 评论 ...
│ │ ├── services/ # 业务逻辑(fish / chat / moment / user ...)
│ │ ├── api/ # auth / fish / chat / moments / ws 路由
│ │ └── ws/manager.py # WebSocket 连接管理
│ └── requirements.txt
├── web/ # 前端 Vue3 + Vite
│ └── src/
│ ├── api/index.ts # axios 封装
│ ├── stores/ # user(pinia) / ws
│ ├── components/ # AvatarC / EmojiPicker / LoginDialog ...
│ └── views/ # PondView / ChatsView / ChatView / MomentsView / MeView / Admin* ...
└── sql/schema.sql # 表结构备份
细心的你可能注意到了 views/ 里有一堆 Admin* 页面——没错,项目还内置了管理后台:用户封禁、内容审核、举报处理、操作日志查询。因为匿名产品不可避免会有垃圾信息,"可运营"是一个真实需求,不是玩具。
五、部署踩坑记(都是真金白银的教训)
上线到云服务器(CentOS 8)的过程,比写代码还"刺激",记录几个坑:
- 系统 Python 太旧:CentOS 8 最高只给到 Python 3.9,但代码用了
str | None(PEP 604,需 3.10+)。用 3.9 的 venv 一启动就报unsupported operand type(s) for |。解决:源码编译 Python 3.11.9 到/opt/python311,所有依赖装进它的 venv。 - MySQL 没开机自启:实例一重启,MySQL 没拉起来,连库直接失败。解决:
systemctl enable mysqld。 - Nginx 反代 WebSocket:
/ws路径必须带Upgrade/Connection头,且proxy_read_timeout设长(我设了 3600s),否则长连接会被 Nginx 提前掐断。 - 单 worker 限制:WebSocket 连接管理器是进程内的,多 worker 会导致推送只到部分连接。解决:暂时单 worker,扩容路线明确写进文档(Redis Pub/Sub)。
- DB_HOST 用回环地址:应用连本机 MySQL 用
127.0.0.1而不是服务器公网 IP,既快又绕开安全组限制。
部署脚本 deploy/setup.sh 做成了幂等:建用户目录 → 拷贝 .env → 建 venv 装依赖 → 注册 systemd 服务 → 配 Nginx(含 firewalld 放行)→ 健康检查,可反复跑。日常只改前端时,上传 web/dist 后 systemctl restart catchfish 即可,零停机风险。
安全提示:
.env里的SECRET_KEY、数据库密码、服务器 IP 都属于敏感信息,部署文档我单独存放并标注"请勿提交公开仓库"。本文也刻意对上述信息做了脱敏——把密钥写进博客是新手最容易踩的雷。
六、一些小数据
- 后端接口约 30 个,覆盖鉴权 / 放鱼摸鱼 / 对话 / 晒网 / 管理后台。
- 数据表约 10 张:用户、鱼帖、摸鱼记录、会话、消息、动态、点赞、评论、封禁、举报。
- 启动时若库为空,自动灌入 3 个示例用户和示例数据(
SEED_DEMO可关),方便第一眼看到"活"的鱼塘。 - 应用中间件还顺手记了一份操作日志(
t_log):谁、从哪个 IP、调了什么接口、返回码多少——运营排查问题时很有用。
七、总结与收获
从一行"想做个摸鱼网站"的念头,到真正跑在公网上的完整产品,这个项目让我把下面这些东西串成了一条线:
- FastAPI 的异步全栈(路由 / 中间件 / WebSocket / 托管前端);
- SQLAlchemy 2.0 async 的事务与并发处理;
- JWT 无状态鉴权里一个小小的
tv指纹设计; - Vue3 组合式 API + Pinia 的状态与事件编排;
- 以及——把代码跑通只是开始,能稳定地在服务器上活下来才是结束。
如果你也想练手全栈,强烈推荐找一个"有点意思"的小主题(而不是又一个 Todo List),兴趣会推着你把鉴权、实时、部署、运营这些"正式项目才有的麻烦"一个个啃下来。
鱼塘已经备好,就等你来放一条鱼 🐟。





在线体验:http://47.101.193.82/#/pond
本文代码均来自个人项目 catchfish(鱼鱼鱼 · 摸鱼聊天系统),技术栈 FastAPI + Vue3 + MySQL。欢迎在评论区交流,也欢迎你基于这个思路做一个自己的版本。
浙公网安备 33010602011771号