上岸有大鱼 上岸有大鱼 | 上岸有大鱼 [微信群:上岸有大鱼] | 博客园 | 管理 | 写随笔

用 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)的过程,比写代码还"刺激",记录几个坑:

  1. 系统 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。
  2. MySQL 没开机自启:实例一重启,MySQL 没拉起来,连库直接失败。解决:systemctl enable mysqld。
  3. Nginx 反代 WebSocket:/ws 路径必须带 Upgrade / Connection 头,且 proxy_read_timeout 设长(我设了 3600s),否则长连接会被 Nginx 提前掐断。
  4. 单 worker 限制:WebSocket 连接管理器是进程内的,多 worker 会导致推送只到部分连接。解决:暂时单 worker,扩容路线明确写进文档(Redis Pub/Sub)。
  5. 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),兴趣会推着你把鉴权、实时、部署、运营这些"正式项目才有的麻烦"一个个啃下来。

鱼塘已经备好,就等你来放一条鱼 🐟。


image

 

image

 

image

 

image

 

image

在线体验:http://47.101.193.82/#/pond

本文代码均来自个人项目 catchfish(鱼鱼鱼 · 摸鱼聊天系统),技术栈 FastAPI + Vue3 + MySQL。欢迎在评论区交流,也欢迎你基于这个思路做一个自己的版本。

posted @ 2026-10-15 19:52  上岸有大鱼  阅读(17)  评论(1)    收藏  举报
上岸有大鱼 上岸有大鱼 | 上岸有大鱼 [微信群:上岸有大鱼]